# neopig

**Neo Python Image Grabber** — A full-domain async media crawler with content-addressable storage, full-text search, and page screenshot capture.

Based on [pig.py](http://russell.ballestrini.net/python-image-grabber-pig-py/) by Russell Ballestrini

**Source:** [git.unturf.com/engineering/unturf/pig.py](https://git.unturf.com/engineering/unturf/pig.py) (public domain)

---

## The Origin Story

> "pig.py is a *very* simple python command line tool to download all the images from a given uri."
> — Russell Ballestrini, August 22, 2011

Back in 2011, Russell Ballestrini created pig.py — a delightfully simple Python script that did one thing well: download all the images from a webpage. It was placed in the public domain, a gift to anyone who needed to grab images from the web.

The original was elegant in its simplicity:

```bash
python pig.py https://www.foxhop.net
```

That was it. Point it at a URL, and it would slurp down every image it could find. No configuration, no complexity — just a hungry little pig gobbling up pixels.

### The Great Bitbucket Extinction

Then came the dark times. The original source lived at ~~[bitbucket.org/russellballestrini/pig](https://bitbucket.org/russellballestrini/pig)~~ — click it, we dare you. In 2020, Atlassian swallowed Bitbucket whole and spat out a glorious fountain of Mercurial repositories into the void. The original pig.py, nestled in its cozy hg repo, was atomized in the great purge.

But here's where it gets *weird*.

Russell once wrote that ["programming is like alchemy — instead of exchanging matter, we programmers exchange time."](https://russell.ballestrini.net/programming-is-like-alchemy/) Programs are golems, familiar spirits, magical servants performing repetitive tasks. *"It is more accurate to group programs with technology than magic, but less fun."*

And speaking of alchemy: [Marathon Fusion](https://phys.org/news/2025-07-marathon-fusion-mercury-gold-energy.html) discovered that tokamak breeding blankets — wrapped in Mercury-Lithium alloy, like pigs in a blanket — can transmute Mercury-198 into Gold-197 through chrysopoeia. Fast neutrons trigger (n, 2n) reactions; unstable mercury decays into stable gold within 64 hours. Two metric tons of gold per gigawatt. The alchemists' dream realized, wrapped in radioactive patience (17.7 years of cooling before you can touch your transmuted treasure).

A golden goose born from the ashes of deprecated version control. The old pig was archived, but a new creature stirred in the digital depths...

### The Evolution to neopig

Like a phoenix rising from dead Bitbucket repos, **neopig** emerged — a chimera, a griffin, a more hungry and gluttonous beast than its predecessor ever dreamed of being. Where pig.py sipped politely from single pages, neopig *devours entire domains*.

---

## Features

- **Async Crawling** — Full-domain recursive crawling with configurable depth, respecting robots.txt and crawl delays
- **Content-Addressed Storage** — MD5-based deduplication in a vault system. Same image from 100 pages? Stored once
- **Full-Text Search** — Every image indexed with its surrounding context: page title, alt text, captions, nearby headings
- **Page Screenshots** — Full-page captures via [uri2png](https://github.com/russellballestrini/uri2png) (wkhtmltoimage, Playwright, etc.)
- **Markdown Conversion** — Intelligent HTML-to-Markdown with forum post detection, noise removal, and content extraction
- **Distributable Archives** — Self-contained tar.gz packages with embedded search servers for offline browsing
- **Multi-Target Crawling** — Crawl multiple domains concurrently
- **Resume Support** — Restart crawls without re-downloading

---

## Quick Start

```bash
# Setup
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

# Crawl a site
python neopig.py https://example.com --mode images

# Run SERP web interface
python serp.py --host 0.0.0.0 --port 31337

# Archive a site into a distributable package
python archive.py https://discourse-urho3d.github.io/
```

---

## Architecture

```
neopig.py             Main CLI crawler (NeoPig class)
archive.py            Site archiver - creates tar.gz packages
async_web_fetcher.py  Async HTTP client, robots.txt, CrawlMode enum
database.py           SQLite schema: crawl_jobs, media, pages, FTS5
filevault.py          Vault + AsyncVault - content-addressed storage
domain_vault.py       Triple vault: HTML, Media, Linkpeek per domain
screenshot.py         ScreenshotCapture wrapper for uri2png
html2md.py            Smart HTML-to-Markdown converter
serp.py               FastAPI web interface (search, live feed, crawl UI)

data/                 Database and state files
  neopig.db           Main SQLite database
vault/                Content-addressed media storage (by MD5 hash)
```

---

## The Skeleton Key Approach

neopig uses what we call the "skeleton key" approach to media indexing. For every image, we capture *all* the text that might help you find it later:

- **Page context:** Title, description, headings near the image
- **Image attributes:** Alt text, title, surrounding captions
- **Link context:** The text of links pointing to the image
- **Detail pages:** For gallery sites, we follow through to detail pages (Pinterest-style)

The result? You can search for "sunset over mountains" and find that image even if it was named `IMG_4372.jpg` with no alt text — because the page title mentioned it, or someone linked to it with descriptive text.

---

## Storage System

### filevault.py — Sync and Async Vaults

The foundation of neopig's storage is `filevault.py`, providing both synchronous (`Vault`) and asynchronous (`AsyncVault`) content-addressable storage.

#### Vault (Synchronous)

```python
from filevault import Vault, content_hash

vault = Vault(
    vaultpath="vault",           # Base storage path
    depth=9,                     # Directory tree depth (9 = 18 hex chars)
    salt="neopig",               # Salt for seed-based hashing
    enable_memory_cache=False    # Optional existence cache
)
vault.init()

# Content-addressable storage
data = b"Hello, World!"
h = content_hash(data)           # MD5 hash
path = vault.store(h, data, ".txt")
retrieved = vault.get(h)

# Seed-based paths (deterministic from arbitrary key)
path = vault.create_filename("user:123", ext="json")
vault.write_json(path, {"key": "value"})
data = vault.read_json(path)
```

#### AsyncVault (Asynchronous)

```python
from filevault import AsyncVault, content_hash

vault = AsyncVault(vaultpath="vault", depth=9)
await vault.init()

# Store media by MD5 hash
data = image_bytes
h = content_hash(data)
path = await vault.store(h, data, ".jpg")

# Check existence
exists = await vault.exists(h)

# Retrieve
data = await vault.get(h)

# Get path
path = await vault.get_path(h)

# Delete
deleted = await vault.delete(h)

# Statistics
stats = await vault.stats()  # {path, depth, count, total_size}
```

**Key Methods (both Vault and AsyncVault):**

| Method | Description |
|--------|-------------|
| `init()` | Create vault base directory |
| `store(hash, data, ext)` | Store content by MD5 hash |
| `exists(hash)` | Check if content exists |
| `get(hash)` | Retrieve bytes by hash |
| `get_path(hash)` | Get Path object for stored file |
| `delete(hash)` | Remove file by hash |
| `create_filename(seed, ext)` | Deterministic path from seed |
| `write_json(path, data)` | Atomic JSON write |
| `read_json(path, default)` | JSON read with fallback |
| `write_text(path, content)` | Text write with locking |
| `read_text(path, default)` | Text read with locking |
| `stats()` | Count files and total size |

**Utility Functions:**

| Function | Description |
|----------|-------------|
| `content_hash(data)` | Generate MD5 hash of bytes |
| `hash_to_path(h, depth, ext)` | Convert hash to directory path |

**Directory Structure:**

With `depth=9`, files are distributed across a 9-layer deep tree using hex pairs:

```
vault/ab/cd/ef/12/34/56/78/9a/bc/abcdef123456789abc....jpg
```

This spreads files across 256^9 possible directories, keeping the filesystem snappy even with hundreds of thousands of files.

---

### domain_vault.py — Triple Vault System

Three separate git-tracked vaults per domain for full site archival. Unlike `filevault.py` (content-addressed by hash), domain vaults store files by **URL path** to mirror original site structure.

**Key differences from filevault:**
- **URL-based paths** — Files stored at paths matching original URLs
- **Git versioning** — Each domain is a git repo (SSH cloneable)
- **Git LFS** — Media and screenshots use LFS for large files
- **Salted privacy** — Domain directories use salted hashes (set `NEOPIG_VAULT_SALT`)

```
html_vault/{9-layers}/{salted_hash}/about/index.html      # Mirrors /about
media_vault/{9-layers}/{salted_hash}/images/logo.png      # Mirrors /images/logo.png
linkpeek_vault/{9-layers}/{salted_hash}/about/index.png   # Screenshot of /about
```

#### 1. DomainHtmlVault

Stores page HTML with git versioning:

```python
from domain_vault import VaultManager

manager = VaultManager(
    html_vault_base='vault/html_vault',
    media_vault_base='vault/media_vault',
    linkpeek_vault_base='vault/linkpeek_vault',
)

html_vault = manager.get_html_vault('example.com')
await html_vault.init()

# Archive a page (saves both original and rewritten versions)
is_changed, chash = await html_vault.archive_page(
    url='https://example.com/about',
    html=html_content,
    media_mappings={'https://cdn.example.com/logo.png': '/media/ab/cd/.../hash.png'}
)

# Retrieve page
html = await html_vault.get_page(url, original=False)  # Rewritten
original_html = await html_vault.get_page(url, original=True)  # Original

# Commit changes
commit_hash = await html_vault.finish_crawl(stats)
```

**Files:**
- `{url_path}/index.html.og` — Original HTML with original URIs
- `{url_path}/index.html` — Rewritten with neopig media paths
- `crawl_log.json` — Crawl history

#### 2. DomainMediaVault

Stores images/videos with git LFS, mirroring original URL paths:

```python
media_vault = manager.get_media_vault('example.com')
await media_vault.init()

is_new, chash, file_path = await media_vault.archive_media(
    url='https://example.com/images/photo.jpg',  # Stored at images/photo.jpg
    content=image_bytes,
    page_url='https://example.com/gallery'
)

content, metadata = await media_vault.get_media(url)
```

#### 3. DomainLinkpeekVault

Stores page screenshots with git LFS. Each page gets one PNG at a path matching its URL:

```python
linkpeek_vault = manager.get_linkpeek_vault('example.com')
await linkpeek_vault.init()

# https://example.com/about -> about/index.png
# https://example.com/blog/post.html -> blog/post.png
is_new, chash, file_path = await linkpeek_vault.archive_screenshot(
    url='https://example.com/about',
    screenshot_data=png_bytes
)

content, metadata = await linkpeek_vault.get_screenshot(url)
```

**Screenshot path mapping:**
| URL | Screenshot Path |
|-----|-----------------|
| `https://example.com/` | `index.png` |
| `https://example.com/about` | `about/index.png` |
| `https://example.com/blog/post.html` | `blog/post.png` |

**Helper Functions:**

| Function | Description |
|----------|-------------|
| `get_vault_salt()` | Get NEOPIG_VAULT_SALT from environment |
| `domain_hash(domain, salted)` | MD5 hash of domain (optionally salted for privacy) |
| `get_filevault_path(base, domain)` | Generate 9-layer deep path for domain |
| `url_to_filepath(url)` | Convert URL to filesystem path |
| `extract_media_urls(html, base_url)` | Extract all media URLs from HTML |

---

## Database

### database.py — SQLAlchemy Models

Async SQLite with WAL mode for concurrent access.

#### Models

**CrawlJob** — Tracks crawl sessions:
```python
class CrawlJob(Base):
    id: int                    # Primary key
    target_uri: str            # Starting URL
    keywords: str              # JSON array
    mode: str                  # 'images', 'videos', 'media', 'all'
    status: str                # 'running', 'completed', 'paused'
    started_at: str            # ISO timestamp
    completed_at: str          # ISO timestamp
    stats: str                 # JSON object with crawl statistics
```

**Media** — Deduplicated content:
```python
class Media(Base):
    md5_hash: str              # Primary key (32 char hex)
    media_type: str            # 'image', 'video', 'audio', 'screenshot'
    mime_type: str             # e.g., 'image/jpeg'
    file_size: int             # Bytes
    score: int                 # Quality score (1=screenshot, 10=full-res)
    first_seen_at: str         # ISO timestamp
    upgraded_at: str           # When score was upgraded
    analysis_status: str       # 'pending', 'completed', 'failed'
    analysis_result: str       # JSON from AI analysis
```

**MediaSource** — Context where media was found:
```python
class MediaSource(Base):
    id: int                    # Primary key
    md5_hash: str              # FK to Media
    media_uri: str             # Original media URL
    page_uri: str              # Page where found
    page_title: str            # Title of page
    page_description: str      # Meta description
    page_content: str          # Extracted text
    alt_text: str              # Image alt attribute
    link_text: str             # Link anchor text
    detail_page_uri: str       # For gallery detail pages
    detail_title: str          # Detail page title
    detail_content: str        # Detail page content
    searchable_text: str       # Combined metadata for FTS
    crawl_job_id: int          # FK to CrawlJob
    discovered_at: str         # ISO timestamp
```

**Page** — Full-text searchable pages:
```python
class Page(Base):
    id: int                    # Primary key
    uri: str                   # Full URL (unique)
    uri_hash: str              # MD5 of URI for clean URLs
    path: str                  # URL path component
    title: str                 # Page title
    description: str           # Meta description
    keywords: str              # JSON array
    content: str               # Extracted text (100KB max)
    markdown: str              # Converted markdown
    raw_html: str              # Original HTML
    crawl_job_id: int          # FK to CrawlJob
    crawled_at: str            # ISO timestamp
```

#### Database Class Methods

```python
db = Database(db_path="data/neopig.db")
await db.init()
```

**Crawl Jobs:**

| Method | Description |
|--------|-------------|
| `create_crawl_job(uri, keywords, mode)` | Create job, returns ID |
| `update_crawl_job_stats(id, stats)` | Update running job stats |
| `complete_crawl_job(id, stats)` | Mark complete |
| `pause_crawl_job(id, stats)` | Mark paused |
| `delete_crawl_job(id)` | Delete job |
| `get_crawl_jobs(limit)` | List recent jobs |
| `get_crawl_job(id)` | Get single job |

**Media Records:**

| Method | Description |
|--------|-------------|
| `create_media_record(hash, uri, page_uri, ...)` | Insert media + source |
| `add_media_source(hash, uri, page_uri, ...)` | Add context for existing media |
| `upgrade_media_score(hash, new_score)` | Upgrade if new score higher |
| `check_media_uri_exists(uri, page_uri)` | Check if already crawled |
| `get_media_by_hash(hash)` | Get single media record |
| `get_media_sources(hash)` | Get all sources for media |
| `get_recent_media(limit, type)` | Recently discovered media |
| `get_recently_upgraded(limit)` | Media with upgraded scores |
| `get_crawled_media_uris()` | All URIs mapped to hashes |
| `get_crawled_screenshot_uris()` | Page URIs with screenshots |
| `get_page_screenshot(page_uri)` | Get screenshot hash for page |
| `get_page_screenshots(page_uri)` | All screenshot hashes (chunked) |

**Pages:**

| Method | Description |
|--------|-------------|
| `store_page(uri, title, content, ...)` | Upsert page record |
| `get_page_by_uri(uri)` | Lookup by URL |
| `get_page_by_hash(uri_hash)` | Lookup by MD5 hash |
| `lookup_pages_by_uris(uris)` | Batch lookup |
| `backfill_page_hashes()` | Generate missing uri_hash values |
| `get_pages_without_screenshots(domain)` | Pages needing screenshots |

**Search:**

| Method | Description |
|--------|-------------|
| `search_pages(query, limit)` | FTS5 search with LIKE fallback |
| `search_media_advanced(q, type, limit)` | Multi-field media search |
| `get_media_by_keyword(keyword, limit)` | Simple keyword search |

**Statistics:**

| Method | Description |
|--------|-------------|
| `get_stats()` | Total media, by type, by analysis status |
| `get_domains_with_pages()` | Domains with archived pages |

---

## Screenshot Capture

### screenshot.py — Multi-Engine Screenshots

Wraps uri2png for async-compatible full-page captures.

#### ScreenshotConfig

```python
from screenshot import ScreenshotCapture, ScreenshotConfig

config = ScreenshotConfig(
    enabled=True,              # Enable/disable screenshots
    width=1024,                # Viewport width
    height=768,                # Viewport height
    delay=1000,                # ms after DOM load
    timeout=30000,             # ms total timeout
    full_page=True,            # Capture full scrollable page
    format='jpeg',             # 'jpeg' or 'png'
    quality=93,                # JPEG quality (1-100)
    engine=None,               # Auto-detect or specify
    user_agent=None,           # Custom user agent
)
```

#### ScreenshotCapture

```python
capture = ScreenshotCapture(config)
await capture.initialize()

# Check availability
if await capture.is_available():
    result = await capture.capture('https://example.com')
    # result = {data, md5_hash, mime_type, size, engine, format}

# Or capture to file
success = await capture.capture_to_file(url, '/path/to/output.jpg')

# Cleanup
await capture.cleanup()
```

**Key Methods:**

| Method | Description |
|--------|-------------|
| `initialize()` | Initialize engine, returns success bool |
| `is_available()` | Check if screenshots work |
| `get_engine_name()` | Get active engine name |
| `list_engines()` | List all available engines |
| `capture(uri, content_length)` | Capture screenshot, returns dict or list for oversized |
| `capture_to_file(uri, path)` | Capture directly to file |
| `calculate_delay_for_content(length)` | Dynamic delay for long pages |
| `cleanup()` | Release engine resources |

**Supported Engines (preference order):**

| Engine | Install | Notes |
|--------|---------|-------|
| cutycapt | `apt install cutycapt` | Native Qt, fast, full-page |
| wkhtmltoimage | `apt install wkhtmltopdf` | Native Qt, fast |
| playwright-webkit | `pip install playwright && playwright install webkit` | Lighter than Chromium |
| playwright-chromium | `pip install playwright && playwright install chromium` | Most compatible |

**Oversized Image Handling:**

JPEG has a 65500px dimension limit. For tall pages, `capture()` returns a list of chunks:

```python
result = await capture.capture('https://example.com/long-page')
if isinstance(result, list):
    for chunk in result:
        # chunk = {data, md5_hash, chunk, total_chunks, ...}
```

---

## HTML to Markdown

### html2md.py — Smart Converter

Detects content structure and generates clean markdown.

```python
from html2md import html_to_markdown

markdown = html_to_markdown(
    html=html_content,
    base_url='https://example.com',
    hint='forum'  # Optional: 'forum', 'blog', 'qa', 'ecommerce'
)
```

#### SmartMarkdownConverter

```python
from html2md import SmartMarkdownConverter

converter = SmartMarkdownConverter(base_url='https://example.com')
markdown = converter.convert(html, hint=None)
```

**Detection Logic:**

1. **Forum** — Discourse, phpBB, vBulletin, XenForo patterns
2. **Q&A** — Stack Exchange style questions/answers
3. **Blog** — Article tags, .post, .entry patterns
4. **E-commerce** — Product info, prices, ratings
5. **Generic** — Fallback clean conversion

**Key Methods:**

| Method | Description |
|--------|-------------|
| `convert(html, hint)` | Main conversion with auto-detection |
| `_remove_noise(soup)` | Strip nav, scripts, ads, etc. |
| `_detect_and_extract(soup)` | Identify content type |
| `_extract_forum_posts(soup, posts)` | Parse forum threads |
| `_extract_qa(soup, questions, answers)` | Parse Q&A pages |
| `_extract_blog(soup, articles)` | Parse blog posts |
| `_extract_ecommerce(soup, products)` | Parse product pages |
| `_element_to_markdown(element)` | Convert single element |
| `_inline_content(element)` | Handle inline formatting |
| `_table_to_markdown(table)` | Convert HTML tables |
| `_resolve_url(url)` | Make URLs absolute |

**Forum Post Extraction:**

For Discourse and similar forums, extracts:
- Avatar URL
- Username
- Timestamp
- Post content
- Quoted content

---

## CLI Reference

### neopig.py

```bash
# Basic crawl
python neopig.py https://example.com --mode images

# Multiple targets
python neopig.py https://site1.com https://site2.com --mode media

# With keywords
python neopig.py https://example.com -k "landscape" "nature" --mode images

# Limit scope
python neopig.py https://example.com --depth 5 --max-pages 500

# Fast mode (no crawl delay)
python neopig.py https://example.com --fast

# Start fresh (ignore resume state)
python neopig.py https://example.com --fresh

# Custom screenshot viewport
python neopig.py https://example.com --screenshot-width 1920 --screenshot-height 1080

# Disable screenshots
python neopig.py https://example.com --no-screenshot

# Specific screenshot engine
python neopig.py https://example.com --screenshot-engine wkhtmltoimage

# List available engines
python neopig.py --list-engines

# Run with live SERP viewer
python neopig.py https://example.com --serve --port 31337

# Backfill markdown for existing pages
python neopig.py --backfill-markdown example.com --trim-wrapper

# Backfill screenshots
python neopig.py --backfill-screenshots example.com --fast
```

### archive.py

```bash
# Archive entire site
python archive.py https://discourse-urho3d.github.io/

# Custom output
python archive.py https://example.com -o ./archives/

# Fast mode
python archive.py https://example.com --fast

# Limit scope
python archive.py https://example.com --depth 5 --max-pages 500

# Without screenshots
python archive.py https://example.com --no-screenshot

# Watch live while archiving
python archive.py https://example.com --serve --port 31337

# Upgrade neopig in existing archive
python archive.py --upgrade-neopig example-20251231.tar.gz
```

### serp.py

```bash
# Start web interface
python serp.py --host 0.0.0.0 --port 31337

# Custom database/vault
python serp.py --db data/neopig.db --vault data/vault
```

---

## API Endpoints (serp.py)

### Web UI

| Route | Description |
|-------|-------------|
| `GET /` | Search interface |
| `GET /crawl` | Crawler UI |
| `GET /live` | Live feed (watch images appear) |
| `GET /random` | Random media or page |
| `GET /phantom` | Phantom site export |
| `GET /about` | About neopig |
| `GET /view/{md5_hash}` | Media detail page |
| `GET /page/{uri_hash}` | Page viewer |

### API

| Route | Description |
|-------|-------------|
| `GET /api/stats` | Database statistics |
| `GET /api/search?q=&type=&limit=` | Search media |
| `GET /api/pages/search?q=` | Search pages |
| `GET /api/media/{md5_hash}` | Media info JSON |
| `POST /api/crawl` | Start crawl job |
| `GET /api/crawl/jobs` | List crawl jobs |
| `GET /api/crawl/jobs/{id}` | Get job status |
| `DELETE /api/crawl/jobs/{id}` | Delete job |
| `GET /health` | Health check |

### Media Serving

| Route | Description |
|-------|-------------|
| `GET /media/{md5_hash}` | Serve media file |
| `GET /media/{bucket}/{filename}` | Direct vault path |

---

## Preserving the Web

The web is ephemeral. Sites go dark. Forums shut down. Communities scatter. neopig is built for preservation — capturing not just the media, but the *context* that gives it meaning.

When you archive a site with neopig, you get:

- Original HTML with rewritten media links pointing to your local vault
- Full-text searchable markdown versions of every page
- Screenshots showing exactly how pages looked
- A SQLite database you can query, backup, and migrate
- Self-extracting archives that work offline forever

---

## The Name

**neopig** = **Neo** (new) + **P**ython **I**mage **G**rabber

A tip of the hat to the original pig.py, with a nod to the Matrix's Neo — seeing through the surface of the web to the underlying content within.

---

## License

neopig is open source. The original pig.py was placed in the public domain by Russell Ballestrini. This project continues that tradition of building useful tools for the community.
