diff --git a/archive.py b/archive.py
index 3b8e6e7..ebb0a5f 100644
--- a/archive.py
+++ b/archive.py
@@ -36,7 +36,7 @@ import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, List, Optional
-from urllib.parse import urlparse
+from miniuri import Uri
import aiofiles
import aiofiles.os
@@ -67,7 +67,7 @@ def sanitize_filename(name: str) -> str:
def url_to_path(url: str) -> str:
"""Convert URL to filesystem path."""
- parsed = urlparse(url)
+ parsed = Uri(url)
path = parsed.path.strip('/')
if not path:
return 'index.html'
@@ -134,8 +134,8 @@ class SiteArchiver:
"""
Archive a site using neopig and package into tar.gz.
"""
- parsed = urlparse(target_url)
- domain = parsed.netloc.lower()
+ parsed = Uri(target_url)
+ domain = parsed.hostname.lower()
date_str = datetime.now().strftime('%Y%m%d')
archive_name = f"{sanitize_filename(domain)}-{date_str}"
diff --git a/async_web_fetcher.py b/async_web_fetcher.py
index edbcc70..2ab7edd 100644
--- a/async_web_fetcher.py
+++ b/async_web_fetcher.py
@@ -22,7 +22,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Dict, List, Tuple, Set, Any
-from urllib.parse import urlparse, urljoin
+from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser
from bs4 import BeautifulSoup
import time
@@ -289,8 +289,8 @@ async def fetch_robots_txt(url: str, user_agent: str = "uncloseai.com/1.42") ->
Returns:
Raw robots.txt content or None if not available
"""
- parsed = urlparse(url)
- robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
+ parsed = Uri(url)
+ robots_url = f"{parsed.scheme}://{parsed.hostname}/robots.txt"
try:
async with aiohttp.ClientSession() as session:
@@ -319,7 +319,7 @@ def analyze_robots_txt_blocking(robots_txt: str, blocked_url: str, user_agent: s
Returns:
Human-readable explanation of the blocking rule
"""
- parsed = urlparse(blocked_url)
+ parsed = Uri(blocked_url)
path = parsed.path or "/"
# Parse robots.txt manually for detailed analysis
@@ -378,7 +378,7 @@ def get_media_type_from_extension(url: str) -> Optional[str]:
Returns:
'image', 'video', 'audio', or None
"""
- parsed = urlparse(url)
+ parsed = Uri(url)
path = parsed.path.lower()
for ext in IMAGE_EXTENSIONS:
@@ -599,8 +599,8 @@ def extract_media_from_html(html: str, base_url: str, mode: CrawlMode = CrawlMod
if href and not get_media_type_from_extension(href):
resolved_detail_url = urljoin(base_url, href)
# Only track same-domain detail pages
- base_domain = urlparse(base_url).netloc
- detail_domain = urlparse(resolved_detail_url).netloc
+ base_domain = Uri(base_url).hostname
+ detail_domain = Uri(resolved_detail_url).hostname
if base_domain != detail_domain:
resolved_detail_url = None
elif detail_page_url:
@@ -830,8 +830,8 @@ class AsyncWebFetcher:
def _get_domain(self, url: str) -> str:
"""Extract domain from URL"""
- parsed = urlparse(url)
- return parsed.netloc
+ parsed = Uri(url)
+ return parsed.hostname
async def _fetch_robots_txt(self, domain: str) -> Optional[RobotFileParser]:
"""
@@ -1096,14 +1096,14 @@ class AsyncWebFetcher:
# CANONICAL URL DETECTION (universal patterns)
# ========================================
canonical_url = None
- detail_parsed = urlparse(detail_page_url)
+ detail_parsed = Uri(detail_page_url)
# Pattern 1: REST-style - check if img src matches URL minus last segment
# e.g., /media/ID/details has img pointing to /media/ID
path_segments = detail_parsed.path.rstrip('/').split('/')
if len(path_segments) > 1:
parent_path = '/'.join(path_segments[:-1])
- parent_url = f"{detail_parsed.scheme}://{detail_parsed.netloc}{parent_path}"
+ parent_url = f"{detail_parsed.scheme}://{detail_parsed.hostname}{parent_path}"
for img in soup.find_all('img', src=True):
src = img.get('src', '')
full_src = urljoin(detail_page_url, src)
@@ -1126,13 +1126,13 @@ class AsyncWebFetcher:
if not img_inside:
continue
# Check it's a media URL or same-domain endpoint
- href_parsed = urlparse(full_href)
+ href_parsed = Uri(full_href)
media_type = get_media_type_from_extension(full_href)
if media_type == 'image':
canonical_url = full_href
break
# Same domain with query params - likely image endpoint
- if href_parsed.netloc == detail_parsed.netloc or not href_parsed.netloc:
+ if href_parsed.hostname == detail_parsed.hostname or not href_parsed.hostname:
canonical_url = full_href
break
@@ -1154,9 +1154,9 @@ class AsyncWebFetcher:
full_src = urljoin(detail_page_url, src)
if full_src == thumbnail_url:
continue
- img_parsed = urlparse(full_src)
+ img_parsed = Uri(full_src)
# Same host
- if img_parsed.netloc == detail_parsed.netloc:
+ if img_parsed.hostname == detail_parsed.hostname:
# Count shared path segments
detail_parts = detail_parsed.path.rstrip('/').split('/')
img_parts = img_parsed.path.rstrip('/').split('/')
@@ -1348,7 +1348,7 @@ class AsyncWebFetcher:
if "nofollow" in rel:
continue
absolute_url = strip_url_fragment(urljoin(url, href))
- parsed = urlparse(absolute_url)
+ parsed = Uri(absolute_url)
if parsed.scheme in ("http", "https"):
if extract_anchor_text:
anchor_text = a_tag.get_text(strip=True)
@@ -1380,7 +1380,7 @@ class AsyncWebFetcher:
if "nofollow" in rel:
continue
absolute_url = strip_url_fragment(urljoin(url, href))
- parsed_link = urlparse(absolute_url)
+ parsed_link = Uri(absolute_url)
if parsed_link.scheme in ("http", "https"):
if extract_anchor_text:
anchor_text = a_tag.get_text(strip=True)
@@ -1494,7 +1494,7 @@ class AsyncWebFetcher:
logger.debug(f"Skipping nofollow link: {href}")
continue
absolute_url = strip_url_fragment(urljoin(url, href))
- parsed = urlparse(absolute_url)
+ parsed = Uri(absolute_url)
if parsed.scheme in ("http", "https"):
if extract_anchor_text:
# Extract anchor text for keyword matching
diff --git a/domain_vault.py b/domain_vault.py
index cdea853..6b5003f 100644
--- a/domain_vault.py
+++ b/domain_vault.py
@@ -48,7 +48,8 @@ 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
+from urllib.parse import urljoin
+from miniuri import Uri
import aiofiles
logger = logging.getLogger(__name__)
@@ -108,7 +109,7 @@ def url_to_filepath(url: str) -> str:
https://example.com/blog/post.html -> blog/post.html
https://example.com/images/logo.png -> images/logo.png
"""
- parsed = urlparse(url)
+ parsed = Uri(url)
path = parsed.path.strip('/')
if not path:
@@ -288,8 +289,8 @@ class DomainHtmlVault:
Example: https://example.com/images/logo.png
-> /media/{9-layers}/{salted_hash}/images/logo.png
"""
- parsed = urlparse(media_url)
- media_domain = parsed.netloc.lower()
+ parsed = Uri(media_url)
+ media_domain = parsed.hostname.lower()
media_path = parsed.path.strip('/')
# Get salted hash for media domain (same across all vaults)
@@ -454,7 +455,7 @@ class DomainMediaVault:
await self.init()
# Use URL path as file path
- parsed = urlparse(url)
+ parsed = Uri(url)
filepath = parsed.path.strip('/')
if not filepath:
# Fallback to content hash
@@ -491,7 +492,7 @@ class DomainMediaVault:
async def get_media(self, url: str) -> Optional[Tuple[bytes, Dict[str, Any]]]:
"""Get archived media content and metadata."""
- parsed = urlparse(url)
+ parsed = Uri(url)
filepath = parsed.path.strip('/')
file_path = self.path / filepath
@@ -572,7 +573,7 @@ class DomainLinkpeekVault:
https://example.com/about -> about/index.png
https://example.com/blog/post.html -> blog/post.png
"""
- parsed = urlparse(url)
+ parsed = Uri(url)
path = parsed.path.strip('/')
if not path:
@@ -712,8 +713,8 @@ class VaultManager:
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]
+ parsed = Uri(url)
+ domain = parsed.hostname 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:
@@ -723,8 +724,8 @@ class VaultManager:
Example: https://example.com/about
-> /linkpeek/{9-layers}/{salted_hash}/about/index.png
"""
- parsed = urlparse(url)
- domain = parsed.netloc.lower()
+ parsed = Uri(url)
+ domain = parsed.hostname.lower()
# Get screenshot path
vault = self.get_linkpeek_vault(domain)
diff --git a/neopig.py b/neopig.py
index 36cc8d8..a91a744 100644
--- a/neopig.py
+++ b/neopig.py
@@ -29,7 +29,7 @@ from datetime import datetime, timezone, timezone
from pathlib import Path
from typing import List, Dict, Any, Optional, Set
-from urllib.parse import urlparse
+from miniuri import Uri
from async_web_fetcher import (
AsyncWebFetcher,
@@ -186,8 +186,8 @@ class NeoPig:
def _get_state_file(self, target_url: str) -> Path:
"""Get unified state file path for domain."""
- parsed = urlparse(target_url)
- domain = parsed.netloc.lower()
+ parsed = Uri(target_url)
+ domain = parsed.hostname.lower()
return get_state_file_path(domain)
def _save_state(self, target_url: str):
@@ -270,8 +270,8 @@ class NeoPig:
def _get_domain(self, url: str) -> str:
"""Extract domain from URL."""
- parsed = urlparse(url)
- return parsed.netloc.lower()
+ parsed = Uri(url)
+ return parsed.hostname.lower()
def _track_domain_stat(self, domain: str, stat: str, increment: int = 1):
"""Track per-domain stats for vault commits."""
@@ -349,7 +349,7 @@ class NeoPig:
# Store page content for full-text search and phantom site recreation
title, content, markdown = self._extract_text_from_html(html, base_url=uri)
- parsed = urlparse(uri)
+ parsed = Uri(uri)
path = parsed.path or '/'
await self.db.store_page(
uri=uri,
@@ -371,7 +371,7 @@ class NeoPig:
"""Create symlink in domain media vault pointing to hash vault."""
domain = self._get_domain(url)
# Domain media path: vault/media_vault/{domain}/{url_path}
- parsed = urlparse(url)
+ parsed = Uri(url)
url_path = parsed.path.lstrip('/') or 'index'
if not url_path.endswith(ext):
url_path = f"{url_path}{ext}"
@@ -398,13 +398,14 @@ class NeoPig:
url: str,
md5_hash: str,
ext: str = 'jpg',
+ suffix: str = '',
):
"""Create symlink in domain linkpeek vault pointing to hash vault."""
domain = self._get_domain(url)
- # Domain screenshot path: vault/linkpeek_vault/{domain}/{url_path}.{ext}
- parsed = urlparse(url)
+ # Domain screenshot path: vault/linkpeek_vault/{domain}/{url_path}{suffix}.{ext}
+ parsed = Uri(url)
url_path = parsed.path.lstrip('/') or 'index'
- url_path = url_path.replace('/', '_') + f'.{ext}'
+ url_path = url_path.replace('/', '_') + f'{suffix}.{ext}'
domain_ss_dir = Path(self.vault_path) / 'linkpeek_vault' / domain
domain_ss_path = domain_ss_dir / url_path
@@ -804,52 +805,57 @@ class NeoPig:
if not result:
return
- md5_hash = result['md5_hash']
- screenshot_data = result['data']
- screenshot_size = len(screenshot_data)
- screenshot_ext = result.get('format', 'png')
- screenshot_mime = result.get('mime_type', 'image/png')
+ # Normalize to list (oversized images return multiple chunks)
+ chunks = result if isinstance(result, list) else [result]
+ total_size = 0
+
+ for i, chunk in enumerate(chunks):
+ md5_hash = chunk['md5_hash']
+ screenshot_data = chunk['data']
+ screenshot_size = len(screenshot_data)
+ screenshot_ext = chunk.get('format', 'png')
+ screenshot_mime = chunk.get('mime_type', 'image/png')
+ total_size += screenshot_size
+
+ # Store in MD5 vault (for deduplication)
+ if not await self.vault.exists(md5_hash):
+ await self.vault.store(md5_hash, screenshot_data, screenshot_ext)
+ self.stats['bytes_stored'] += screenshot_size
+
+ # Create symlink in linkpeek vault (with suffix for chunks)
+ suffix = f'_{i}' if len(chunks) > 1 else ''
+ await self._archive_screenshot_to_vault(page_uri, md5_hash, screenshot_ext, suffix=suffix)
+
+ # Record in database (all chunks, with chunk index in alt_text)
+ chunk_label = f" (part {i+1}/{len(chunks)})" if len(chunks) > 1 else ""
+ await self.db.create_media_record(
+ md5_hash=md5_hash,
+ media_uri=f"screenshot:{page_uri}{suffix}",
+ page_uri=page_uri,
+ crawl_job_id=job_id,
+ media_type='screenshot',
+ mime_type=screenshot_mime,
+ file_size=screenshot_size,
+ page_title=page_title,
+ page_description='',
+ page_keywords='',
+ alt_text=f"Screenshot of {page_uri}{chunk_label}",
+ link_text='',
+ )
# Screenshots are fetched by headless browser (network traffic)
- self.stats['bytes_downloaded'] += screenshot_size
-
- # Store in MD5 vault (for deduplication)
- if not await self.vault.exists(md5_hash):
- await self.vault.store(md5_hash, screenshot_data, screenshot_ext)
- self.stats['bytes_stored'] += screenshot_size
-
- # Create symlink in linkpeek vault pointing to hash vault
- await self._archive_screenshot_to_vault(page_uri, md5_hash, screenshot_ext)
-
- # 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=screenshot_mime,
- 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['bytes_downloaded'] += total_size
self.stats['screenshots_taken'] += 1
self.seen_screenshots.add(page_uri) # Only mark seen after success
- logger.debug(f"Screenshot captured: {page_uri} -> {md5_hash}")
+ logger.debug(f"Screenshot captured: {page_uri} -> {chunks[0]['md5_hash']} ({len(chunks)} chunk(s))")
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
-
# Try from URL path
- path = urlparse(url).path.lower()
+ path = Uri(url).path.lower()
for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp',
'.mp4', '.webm', '.mov', '.avi', '.mkv']:
if path.endswith(ext):
@@ -1013,7 +1019,7 @@ async def backfill_screenshots(
await db.init()
async with db.session() as session:
- # Find all screenshot records using ORM
+ # Find all screenshot records using ORM, excluding already-completed URIs
if domain_filter:
pattern = f"%://{domain_filter}%" if '://' not in domain_filter else f"%{domain_filter}%"
stmt = (
@@ -1021,6 +1027,7 @@ async def backfill_screenshots(
.join(Media, MediaSource.md5_hash == Media.md5_hash)
.where(Media.media_type == 'screenshot')
.where(MediaSource.page_uri.like(pattern))
+ .where(MediaSource.page_uri.notin_(completed_uris) if completed_uris else True)
.distinct()
)
logger.info(f"Backfilling screenshots for domain: {domain_filter}")
@@ -1029,35 +1036,32 @@ async def backfill_screenshots(
select(MediaSource.page_uri, Media.md5_hash)
.join(Media, MediaSource.md5_hash == Media.md5_hash)
.where(Media.media_type == 'screenshot')
+ .where(MediaSource.page_uri.notin_(completed_uris) if completed_uris else True)
.distinct()
)
logger.info("Backfilling screenshots for ALL pages")
result = await session.execute(stmt)
rows = result.fetchall()
- total = len(rows)
- logger.info(f"Found {total} screenshots to re-capture...")
+ to_process = len(rows)
+ skipped_count = len(completed_uris)
+ total = to_process + skipped_count
+ logger.info(f"Found {to_process} to process ({skipped_count} already completed)")
captured = 0
- skipped = 0
failed = 0
bytes_saved = 0
domain_last_fetched = {} # Track last fetch time per domain
crawl_delay = 2.0 # Default crawl delay in seconds
- pbar = tqdm(rows, desc="Screenshots", unit="pages")
- for row in pbar:
+ pbar = tqdm(total=total, initial=skipped_count, desc="Screenshots", unit="pages")
+ for row in rows:
page_uri = row.page_uri
old_hash = row.md5_hash
- domain = urlparse(page_uri).netloc.lower()
+ domain = Uri(page_uri).hostname.lower()
- # Skip if already completed (from state file)
- if page_uri in completed_uris:
- skipped += 1
- continue
-
- # Log current page
- logger.info(f"Capturing: {page_uri}")
+ # Log current page (debug level to reduce noise)
+ logger.debug(f"Capturing: {page_uri}")
try:
# Enforce crawl delay (skip in fast mode)
@@ -1079,43 +1083,56 @@ async def backfill_screenshots(
result = await capture.capture(page_uri, content_length=content_length)
if not result:
failed += 1
+ pbar.update(1)
continue
- new_hash = result['md5_hash']
- new_data = result['data']
- new_ext = result.get('format', 'jpg')
- new_mime = result.get('mime_type', 'image/jpeg')
- new_size = len(new_data)
+ # Normalize to list (oversized images return multiple chunks)
+ chunks = result if isinstance(result, list) else [result]
# Get old file size for comparison
old_path = Path(vault_path) / old_hash[:2] / f"{old_hash}.png"
old_size = old_path.stat().st_size if old_path.exists() else 0
- # Store new screenshot
- if not await vault.exists(new_hash):
- await vault.store(new_hash, new_data, new_ext)
+ # Store all chunks
+ total_new_size = 0
+ for i, chunk in enumerate(chunks):
+ new_hash = chunk['md5_hash']
+ new_data = chunk['data']
+ new_ext = chunk.get('format', 'jpg')
+ new_mime = chunk.get('mime_type', 'image/jpeg')
+ total_new_size += len(new_data)
+
+ if not await vault.exists(new_hash):
+ await vault.store(new_hash, new_data, new_ext)
+
+ # Create symlink for each chunk
+ parsed = Uri(page_uri)
+ url_path = parsed.path.lstrip('/') or 'index'
+ suffix = f'_{i}' if len(chunks) > 1 else ''
+ new_symlink = Path(vault_path) / 'linkpeek_vault' / domain / (url_path.replace('/', '_') + f'{suffix}.{new_ext}')
+ new_symlink.parent.mkdir(parents=True, exist_ok=True)
+ hash_vault_path = Path(vault_path) / new_hash[:2] / f"{new_hash}.{new_ext}"
+ rel_path = os.path.relpath(hash_vault_path, new_symlink.parent)
+ if not new_symlink.exists():
+ new_symlink.symlink_to(rel_path)
# Delete old PNG file FIRST (before metadata update)
- if delete_old and old_hash != new_hash and old_path.exists():
+ if delete_old and old_path.exists():
old_path.unlink()
- bytes_saved += old_size - new_size
-
- # Update linkpeek symlink
- parsed = urlparse(page_uri)
- url_path = parsed.path.lstrip('/') or 'index'
+ bytes_saved += old_size - total_new_size
# Remove old symlink
+ parsed = Uri(page_uri)
+ url_path = parsed.path.lstrip('/') or 'index'
old_symlink = Path(vault_path) / 'linkpeek_vault' / domain / (url_path.replace('/', '_') + '.png')
if old_symlink.exists():
old_symlink.unlink()
- # Create new symlink
- new_symlink = Path(vault_path) / 'linkpeek_vault' / domain / (url_path.replace('/', '_') + f'.{new_ext}')
- new_symlink.parent.mkdir(parents=True, exist_ok=True)
- hash_vault_path = Path(vault_path) / new_hash[:2] / f"{new_hash}.{new_ext}"
- rel_path = os.path.relpath(hash_vault_path, new_symlink.parent)
- if not new_symlink.exists():
- new_symlink.symlink_to(rel_path)
+ # Use first chunk for database record
+ new_hash = chunks[0]['md5_hash']
+ new_ext = chunks[0].get('format', 'jpg')
+ new_mime = chunks[0].get('mime_type', 'image/jpeg')
+ new_size = total_new_size
# Update database using ORM
# md5_hash is PRIMARY KEY, so we need to: insert new -> update refs -> delete old
@@ -1175,12 +1192,18 @@ async def backfill_screenshots(
full_state['backfill_screenshots'] = list(completed_uris)
state_path.write_text(json.dumps(full_state, indent=2))
- except Exception as e:
- logger.warning(f"Error re-capturing {page_uri}: {e}")
- failed += 1
+ pbar.update(1)
+ except Exception as e:
+ # Truncate error message for cleaner logs
+ err_msg = str(e).split('\n')[0][:60]
+ logger.warning(f"Failed: {page_uri} - {err_msg}")
+ failed += 1
+ pbar.update(1)
+
+ pbar.close()
await session.commit()
- logger.info(f"Backfill complete: {captured} captured, {skipped} skipped, {failed} failed")
+ logger.info(f"Backfill complete: {captured} captured, {skipped_count} skipped, {failed} failed")
if bytes_saved > 0:
logger.info(f"Space saved: {bytes_saved / 1024 / 1024:.1f} MB")
diff --git a/screenshot.py b/screenshot.py
index 5b4e980..ce47ec2 100644
--- a/screenshot.py
+++ b/screenshot.py
@@ -22,6 +22,7 @@ import hashlib
import io
import logging
import shutil
+import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, List, Dict, Any
@@ -29,6 +30,9 @@ import tempfile
from PIL import Image
+# Suppress PIL decompression bomb warnings for large screenshots
+warnings.filterwarnings('ignore', category=Image.DecompressionBombWarning)
+
logger = logging.getLogger(__name__)
# Engine preference order - lightest/fastest first
@@ -258,7 +262,9 @@ class ScreenshotCapture:
)
if not result.success:
- logger.warning(f"Screenshot failed: {uri} - {result.error}")
+ # Extract just the first line of error, skip wkhtmltoimage verbosity
+ error_msg = (result.error or 'unknown error').split('\n')[0][:80]
+ logger.warning(f"Screenshot failed: {uri} - {error_msg}")
return None
# Read bytes from output file
@@ -270,9 +276,40 @@ class ScreenshotCapture:
# Convert to JPEG if configured
if self.config.format == 'jpeg':
img = Image.open(io.BytesIO(png_data))
+
# Convert RGBA to RGB (JPEG doesn't support alpha)
if img.mode in ('RGBA', 'LA', 'P'):
img = img.convert('RGB')
+
+ # Split oversized images (JPEG max is 65500px)
+ max_dim = 65000
+ if img.height > max_dim:
+ logger.debug(f"Splitting oversized image: {img.height}px into chunks")
+ results = []
+ chunk_idx = 0
+ y = 0
+ while y < img.height:
+ chunk_height = min(max_dim, img.height - y)
+ chunk = img.crop((0, y, img.width, y + chunk_height))
+ output = io.BytesIO()
+ chunk.save(output, format='JPEG', quality=self.config.quality, optimize=True)
+ chunk_data = output.getvalue()
+ chunk_hash = hashlib.md5(chunk_data).hexdigest()
+ results.append({
+ 'data': chunk_data,
+ 'md5_hash': chunk_hash,
+ 'mime_type': 'image/jpeg',
+ 'size': len(chunk_data),
+ 'source_uri': uri,
+ 'engine': self._engine_name,
+ 'format': 'jpg',
+ 'chunk': chunk_idx,
+ 'total_chunks': (img.height + max_dim - 1) // max_dim,
+ })
+ y += max_dim
+ chunk_idx += 1
+ return results # Return list for oversized images
+
output = io.BytesIO()
img.save(output, format='JPEG', quality=self.config.quality, optimize=True)
data = output.getvalue()
diff --git a/serp.py b/serp.py
index d4c5129..667a0b3 100644
--- a/serp.py
+++ b/serp.py
@@ -32,6 +32,7 @@ from sqlalchemy import text
import uvicorn
from database import Database
+from miniuri import Uri
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -65,6 +66,84 @@ ARCHIVE_ROOT: str = None # e.g., "example.com-20251230"
TEMP_DB_PATH: str = None # Extracted database (SQLite needs real file)
+# Shared CSS for view pages
+VIEW_CSS = """
+* { box-sizing: border-box; }
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ margin: 0; padding: 0;
+ background: #0a0a0a; color: #e0e0e0;
+}
+.nav {
+ background: #1a1a1a; padding: 10px 20px;
+ display: flex; gap: 20px; align-items: center;
+ border-bottom: 1px solid #333;
+}
+.nav a { color: #ff6b6b; text-decoration: none; }
+.nav a:hover { text-decoration: underline; }
+.nav .brand { font-weight: bold; font-size: 18px; }
+.container { padding: 20px; max-width: 1200px; margin: 0 auto; }
+h1 { color: #ff6b6b; font-size: 20px; margin: 0 0 10px 0; }
+h3 { color: #ff6b6b; font-size: 16px; margin: 30px 0 15px 0; border-bottom: 1px solid #333; padding-bottom: 8px; }
+a { color: #ff6b6b; }
+.meta { background: #1a1a1a; padding: 15px; border-radius: 8px; margin: 15px 0; }
+.meta-row { display: flex; margin: 8px 0; }
+.meta-label { width: 100px; color: #888; font-size: 13px; }
+.meta-value { flex: 1; word-break: break-all; font-size: 13px; }
+.meta-value a { color: #4ade80; }
+.hero { text-align: center; margin-bottom: 20px; }
+.hero img, .hero video { max-width: 100%; max-height: 60vh; border-radius: 8px; }
+.content-rendered {
+ background: #1a1a1a; padding: 20px; border-radius: 8px;
+ line-height: 1.7; font-size: 14px; color: #ccc;
+}
+.content-rendered img { max-width: 100%; height: auto; margin: 10px 0; }
+.content-rendered img.avatar {
+ display: inline-block; vertical-align: middle;
+ width: 40px; height: 40px; border-radius: 50%;
+ margin: 0 10px 0 0; object-fit: cover;
+}
+.content-rendered img.emoji {
+ display: inline; width: 20px; height: 20px;
+ margin: 0 2px; vertical-align: text-bottom;
+}
+.content-rendered a { color: #ff6b6b; }
+.content-rendered pre, .content-rendered code {
+ background: #252525; padding: 2px 6px;
+ border-radius: 4px; font-family: monospace; font-size: 13px;
+}
+.content-rendered pre {
+ padding: 15px; display: block;
+ white-space: pre-wrap; overflow-x: auto;
+}
+.content-rendered blockquote {
+ background: #151520; border-left: 3px solid #4a9eff;
+ padding: 12px 16px; margin: 16px 0;
+ border-radius: 0 6px 6px 0; color: #aaa; font-style: italic;
+}
+.content-rendered hr { border: none; border-top: 1px solid #333; margin: 24px 0; }
+.content-rendered p { margin: 0 0 16px 0; line-height: 1.7; }
+.content-rendered h1, .content-rendered h2, .content-rendered h3 {
+ color: #ff6b6b; margin-top: 28px; margin-bottom: 12px;
+}
+.media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 10px; margin-top: 15px;
+}
+.media-card {
+ background: #1a1a1a; border-radius: 8px;
+ overflow: hidden; display: block; transition: transform 0.2s;
+}
+.media-card:hover { transform: scale(1.02); }
+.media-card img, .media-card video {
+ width: 100%; height: 120px;
+ object-fit: contain; background: #0a0a0a;
+}
+.tag { background: #333; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-right: 5px; }
+"""
+
+
def read_from_tarball(path: str) -> bytes:
"""Read a file from the tarball. Path is relative to archive root."""
if not TAR_FILE or not ARCHIVE_ROOT:
@@ -1231,6 +1310,10 @@ async def view_media_page(md5_hash: str):
# Get page URI for content lookup
page_uri = sorted_sources[0]["page_uri"] if sorted_sources else None
+ # Extract domain for AI system prompt
+ source_uri = page_uri or (sorted_sources[0]["media_uri"] if sorted_sources else "unknown")
+ source_domain = Uri(source_uri).hostname if source_uri else "unknown"
+
# Get page content - prefer markdown, fallback to raw HTML or text
page_content_html = ""
@@ -1756,6 +1839,9 @@ async def view_media_page(md5_hash: str):
// Run after DOM ready (avatars may be empty placeholders)
restructureForumPosts();
+