- Change default port from 8000 to 31337 across all files - About page: and → &, beast → pig - Search results: SVG placeholder for code files (shows extension) - Download button: remove .bin fallback, use original extension
729 lines
24 KiB
Markdown
729 lines
24 KiB
Markdown
# 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.
|