modified: .gitignore

modified:   database.py
	modified:   html2md.py
	modified:   neopig.py
	modified:   screenshot.py
	modified:   serp.py
This commit is contained in:
Russell Ballestrini 2025-12-30 11:35:25 -05:00
parent 3952978ba4
commit 293ed64a3b
6 changed files with 158 additions and 56 deletions

1
.gitignore vendored
View file

@ -8,7 +8,6 @@ venv/
# Database and vault (generated data) # Database and vault (generated data)
*.db *.db
vault/
test_vault/ test_vault/
# Vendored dependencies # Vendored dependencies

View file

@ -669,6 +669,21 @@ class Database:
row = result.fetchone() row = result.fetchone()
return row[0] if row else None return row[0] if row else None
async def get_page_screenshots(self, page_uri: str) -> List[str]:
"""Get all screenshot hashes for a page (for chunked screenshots). Returns list of md5_hash ordered by file size desc."""
async with self.session() as session:
stmt = (
select(Media.md5_hash)
.join(MediaSource, Media.md5_hash == MediaSource.md5_hash)
.where(and_(
MediaSource.page_uri == page_uri,
Media.media_type == 'screenshot'
))
.order_by(Media.file_size.desc())
)
result = await session.execute(stmt)
return [row[0] for row in result.fetchall()]
async def get_recent_media(self, limit: int = 50, media_type: str = None) -> List[Dict[str, Any]]: async def get_recent_media(self, limit: int = 50, media_type: str = None) -> List[Dict[str, Any]]:
"""Get recently discovered media.""" """Get recently discovered media."""
async with self.session() as session: async with self.session() as session:

View file

@ -297,10 +297,21 @@ class SmartMarkdownConverter:
# Block elements # Block elements
if tag in ('p', 'div'): if tag in ('p', 'div'):
content = self._inline_content(el) # Check if contains block-level elements (pre, ul, ol, etc.)
if content: block_tags = {'pre', 'ul', 'ol', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table'}
lines.append(content) has_blocks = any(
lines.append('') isinstance(c, Tag) and c.name and c.name.lower() in block_tags
for c in el.children
)
if has_blocks:
# Recursively process children to preserve block structure
for child in el.children:
self._walk_element(child, lines, depth)
else:
content = self._inline_content(el)
if content:
lines.append(content)
lines.append('')
elif tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'): elif tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
level = int(tag[1]) level = int(tag[1])

View file

@ -915,13 +915,14 @@ def trim_html_wrapper(html: str) -> str:
return str(soup) return str(soup)
async def backfill_markdown(db_path: str, domain_filter: str = None, trim_wrapper: bool = False): async def backfill_markdown(db_path: str, domain_filter: str = None, trim_wrapper: bool = False, quiet: bool = False):
"""Re-process stored HTML to regenerate markdown with smart structure detection. """Re-process stored HTML to regenerate markdown with smart structure detection.
Args: Args:
db_path: Path to SQLite database db_path: Path to SQLite database
domain_filter: Only process pages matching this domain (e.g., 'example.com') domain_filter: Only process pages matching this domain (e.g., 'example.com')
trim_wrapper: Strip nav/header/footer/logo before conversion (applied before smart conversion) trim_wrapper: Strip nav/header/footer/logo before conversion (applied before smart conversion)
quiet: Disable progress bar
""" """
from html2md import html_to_markdown from html2md import html_to_markdown
import aiosqlite import aiosqlite
@ -933,28 +934,25 @@ async def backfill_markdown(db_path: str, domain_filter: str = None, trim_wrappe
if domain_filter: if domain_filter:
# Match domain in URI # Match domain in URI
pattern = f"%://{domain_filter}%" if '://' not in domain_filter else f"%{domain_filter}%" pattern = f"%://{domain_filter}%" if '://' not in domain_filter else f"%{domain_filter}%"
count_sql = "SELECT COUNT(*) FROM pages WHERE raw_html IS NOT NULL AND uri LIKE ?"
select_sql = "SELECT id, uri, raw_html FROM pages WHERE raw_html IS NOT NULL AND uri LIKE ?" select_sql = "SELECT id, uri, raw_html FROM pages WHERE raw_html IS NOT NULL AND uri LIKE ?"
params = (pattern,) params = (pattern,)
logger.info(f"Backfilling markdown for domain: {domain_filter}") logger.info(f"Backfilling markdown for domain: {domain_filter}")
else: else:
count_sql = "SELECT COUNT(*) FROM pages WHERE raw_html IS NOT NULL"
select_sql = "SELECT id, uri, raw_html FROM pages WHERE raw_html IS NOT NULL" select_sql = "SELECT id, uri, raw_html FROM pages WHERE raw_html IS NOT NULL"
params = () params = ()
logger.info("Backfilling markdown for ALL pages (no domain filter)") logger.info("Backfilling markdown for ALL pages")
if trim_wrapper: if trim_wrapper:
logger.info("Trim wrapper enabled: stripping nav/header/footer/logo before conversion") logger.info("Trim wrapper enabled")
cursor = await db.execute(count_sql, params)
total = (await cursor.fetchone())[0]
logger.info(f"Found {total} pages to process...")
cursor = await db.execute(select_sql, params) cursor = await db.execute(select_sql, params)
rows = await cursor.fetchall() rows = await cursor.fetchall()
total = len(rows)
logger.info(f"Found {total} pages to process")
updated = 0 updated = 0
for row in rows: errors = 0
for row in tqdm(rows, desc="Markdown", unit="pages", disable=quiet):
if not row['raw_html']: if not row['raw_html']:
continue continue
@ -967,12 +965,12 @@ async def backfill_markdown(db_path: str, domain_filter: str = None, trim_wrappe
updated += 1 updated += 1
if updated % 100 == 0: if updated % 100 == 0:
await db.commit() await db.commit()
logger.info(f" Processed {updated}/{total} pages...")
except Exception as e: except Exception as e:
logger.warning(f"Error processing {row['uri']}: {e}") errors += 1
logger.debug(f"Error processing {row['uri']}: {e}")
await db.commit() await db.commit()
logger.info(f"Backfill complete: {updated} pages updated") logger.info(f"Backfill complete: {updated} updated, {errors} errors")
async def backfill_screenshots( async def backfill_screenshots(
@ -981,6 +979,7 @@ async def backfill_screenshots(
domain_filter: str = None, domain_filter: str = None,
delete_old: bool = True, delete_old: bool = True,
fast_mode: bool = False, fast_mode: bool = False,
quiet: bool = False,
): ):
"""Re-capture screenshots as JPEG to replace old PNGs. """Re-capture screenshots as JPEG to replace old PNGs.
@ -990,6 +989,7 @@ async def backfill_screenshots(
domain_filter: Only process pages matching this domain domain_filter: Only process pages matching this domain
delete_old: Delete old PNG files after successful JPEG capture delete_old: Delete old PNG files after successful JPEG capture
fast_mode: Skip delay between captures (for sites without robots.txt) fast_mode: Skip delay between captures (for sites without robots.txt)
quiet: Disable progress bar
""" """
from screenshot import ScreenshotCapture, ScreenshotConfig from screenshot import ScreenshotCapture, ScreenshotConfig
from storage import ImageVault from storage import ImageVault
@ -1054,8 +1054,22 @@ async def backfill_screenshots(
domain_last_fetched = {} # Track last fetch time per domain domain_last_fetched = {} # Track last fetch time per domain
crawl_delay = 2.0 # Default crawl delay in seconds crawl_delay = 2.0 # Default crawl delay in seconds
pbar = tqdm(total=total, initial=skipped_count, desc="Screenshots", unit="pages") # Concurrent workers in fast mode (based on CPU count)
for row in rows: import multiprocessing
max_workers = multiprocessing.cpu_count() if fast_mode else 1
semaphore = asyncio.Semaphore(max_workers)
if fast_mode and max_workers > 1:
logger.info(f"Fast mode: {max_workers} concurrent workers")
pbar = tqdm(total=total, initial=skipped_count, desc="Screenshots", unit="pages", disable=quiet)
# Lock for thread-safe updates
state_lock = asyncio.Lock()
stats = {'captured': 0, 'failed': 0, 'bytes_saved': 0}
async def process_row(row):
nonlocal completed_uris
async with semaphore:
page_uri = row.page_uri page_uri = row.page_uri
old_hash = row.md5_hash old_hash = row.md5_hash
domain = Uri(page_uri).hostname.lower() domain = Uri(page_uri).hostname.lower()
@ -1337,6 +1351,12 @@ async def main():
help="With --backfill-markdown: strip nav/header/footer/logo before conversion" help="With --backfill-markdown: strip nav/header/footer/logo before conversion"
) )
parser.add_argument(
"-q", "--quiet",
action="store_true",
help="Disable progress bars"
)
parser.add_argument( parser.add_argument(
"--backfill-screenshots", "--backfill-screenshots",
metavar="DOMAIN", metavar="DOMAIN",
@ -1386,7 +1406,7 @@ async def main():
# Handle --backfill-markdown # Handle --backfill-markdown
if args.backfill_markdown: if args.backfill_markdown:
await backfill_markdown(args.db, domain_filter=args.backfill_markdown, trim_wrapper=args.trim_wrapper) await backfill_markdown(args.db, domain_filter=args.backfill_markdown, trim_wrapper=args.trim_wrapper, quiet=args.quiet)
return return
# Handle --backfill-screenshots # Handle --backfill-screenshots
@ -1416,6 +1436,7 @@ async def main():
domain_filter=args.backfill_screenshots, domain_filter=args.backfill_screenshots,
delete_old=not args.keep_old_screenshots, delete_old=not args.keep_old_screenshots,
fast_mode=args.fast, fast_mode=args.fast,
quiet=args.quiet,
) )
finally: finally:
if serp_process: if serp_process:

View file

@ -194,13 +194,13 @@ class ScreenshotCapture:
content_length: Length of raw HTML content in bytes content_length: Length of raw HTML content in bytes
Returns: Returns:
Delay in milliseconds (min 2000ms, max 30000ms) Delay in milliseconds (min 3000ms, max 60000ms)
""" """
# Base delay of 2 seconds # Base delay of 3 seconds
base_delay = 2000 base_delay = 3000
# Add 2 seconds per 50KB of content, up to a max of 28 seconds # Add 3 seconds per 50KB of content, up to a max of 57 seconds
additional_delay = min((content_length // 50000) * 2000, 28000) additional_delay = min((content_length // 50000) * 3000, 57000)
return base_delay + additional_delay return base_delay + additional_delay

116
serp.py
View file

@ -1912,13 +1912,12 @@ async def view_page(
escaped = html_module.escape(page["content"][:50000]) escaped = html_module.escape(page["content"][:50000])
content_html = f"<pre style='white-space:pre-wrap;'>{escaped}</pre>" content_html = f"<pre style='white-space:pre-wrap;'>{escaped}</pre>"
# Find screenshot # Find all screenshots (may be chunked for tall pages)
screenshot_hashes = await db.get_page_screenshots(uri)
screenshot_html = "" screenshot_html = ""
screenshot_hash = await db.get_page_screenshot(uri) if screenshot_hashes:
if screenshot_hash: imgs = ''.join(f'<img src="/media/{h}" alt="Page screenshot chunk" style="width:100%;display:block;">' for h in screenshot_hashes)
screenshot_html = f'''<a href="/media/{screenshot_hash}" target="_blank"> screenshot_html = f'<div class="screenshot-stack">{imgs}</div>'
<img src="/media/{screenshot_hash}" alt="Page screenshot" style="max-width:100%;max-height:50vh;border-radius:8px;">
</a>'''
# Find media from this page # Find media from this page
media_items = await db.get_page_media(uri) media_items = await db.get_page_media(uri)
@ -1942,13 +1941,36 @@ async def view_page(
<div class="media-grid">{''.join(media_cards)}</div> <div class="media-grid">{''.join(media_cards)}</div>
</div>''' </div>'''
has_screenshot = bool(screenshot_html)
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html> <html>
<head> <head>
<title>{html_module.escape(page_title)} - neopig</title> <title>{html_module.escape(page_title)} - neopig</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>{VIEW_CSS}</style> <style>
{VIEW_CSS}
.page-layout {{ display: flex; gap: 20px; }}
.page-main {{ flex: 1; min-width: 0; }}
.page-sidebar {{ width: 400px; flex-shrink: 0; transition: width 0.3s, opacity 0.3s; }}
.page-sidebar.collapsed {{ width: 40px; overflow: hidden; }}
.page-sidebar.collapsed .screenshot-stack {{ opacity: 0; }}
.sidebar-toggle {{
cursor: pointer; padding: 8px 12px; background: #333; border-radius: 4px;
margin-bottom: 10px; display: inline-block; user-select: none;
}}
.sidebar-toggle:hover {{ background: #444; }}
.screenshot-stack {{ max-height: 80vh; overflow-y: auto; border-radius: 8px; }}
.screenshot-stack img {{ border-radius: 0; }}
.screenshot-stack img:first-child {{ border-radius: 8px 8px 0 0; }}
.screenshot-stack img:last-child {{ border-radius: 0 0 8px 8px; }}
.screenshot-stack img:only-child {{ border-radius: 8px; }}
@media (max-width: 900px) {{
.page-layout {{ flex-direction: column; }}
.page-sidebar {{ width: 100%; }}
.page-sidebar.collapsed {{ width: 100%; height: 40px; }}
}}
</style>
</head> </head>
<body> <body>
<div class="nav"> <div class="nav">
@ -1958,19 +1980,42 @@ async def view_page(
<a href="/random">Random</a> <a href="/random">Random</a>
</div> </div>
<div class="container"> <div class="container">
<div class="hero">
{screenshot_html or ''}
</div>
<h1>{html_module.escape(page_title)}</h1> <h1>{html_module.escape(page_title)}</h1>
<div class="meta"> <div class="meta">
<div class="meta-row"><span class="meta-label">URL:</span><span class="meta-value"><a href="{uri}" target="_blank">{uri}</a></span></div> <div class="meta-row"><span class="meta-label">URL:</span><span class="meta-value"><a href="{uri}" target="_blank">{uri}</a></span></div>
<div class="meta-row"><span class="meta-label">Media:</span><span class="meta-value">{len(media_items)} items</span></div> <div class="meta-row"><span class="meta-label">Media:</span><span class="meta-value">{len(media_items)} items</span></div>
</div> </div>
{media_grid} <div class="page-layout">
<h3>Page Content</h3> <div class="page-main">
<div class="content-rendered">{content_html or '<p>No content available</p>'}</div> {media_grid}
<h3>Page Content</h3>
<div class="content-rendered">{content_html or '<p>No content available</p>'}</div>
</div>
{f'''<div class="page-sidebar" id="sidebar">
<span class="sidebar-toggle" onclick="toggleSidebar()"> Screenshot</span>
{screenshot_html}
</div>''' if has_screenshot else ''}
</div>
</div> </div>
<script>hljs.highlightAll();</script> <script>
hljs.highlightAll();
function toggleSidebar() {{
const sb = document.getElementById('sidebar');
const toggle = sb.querySelector('.sidebar-toggle');
sb.classList.toggle('collapsed');
const isCollapsed = sb.classList.contains('collapsed');
toggle.textContent = isCollapsed ? '▶ Screenshot' : '◀ Screenshot';
localStorage.setItem('neopig_sidebar_collapsed', isCollapsed);
}}
// Restore state from localStorage
if (localStorage.getItem('neopig_sidebar_collapsed') === 'true') {{
const sb = document.getElementById('sidebar');
if (sb) {{
sb.classList.add('collapsed');
sb.querySelector('.sidebar-toggle').textContent = '▶ Screenshot';
}}
}}
</script>
{'' if noai else f'''<script> {'' if noai else f'''<script>
window.UNCLOSEAI_SYSTEM_PROMPT = "This is an archived copy of {source_domain} preserved by neopig. The original site may no longer exist. You are viewing: {page_title} ({uri}). Help users explore the preserved discussions, media, and pages."; window.UNCLOSEAI_SYSTEM_PROMPT = "This is an archived copy of {source_domain} preserved by neopig. The original site may no longer exist. You are viewing: {page_title} ({uri}). Help users explore the preserved discussions, media, and pages.";
</script> </script>
@ -2269,27 +2314,38 @@ async def get_stats_endpoint():
@app.get("/random") @app.get("/random")
async def random_media(): async def random_item(type: str = Query(None, description="Type: media or page (random if not specified)")):
"""Redirect to a random media item.""" """Redirect to a random media item or page."""
from sqlalchemy import select, func from sqlalchemy import select, func
from database import Media from database import Media, Page
import random import random
async with db.session() as session: # If no type specified, randomly pick between media and page
# Get a random media item (excluding screenshots) if type is None:
stmt = ( type = random.choice(["media", "page"])
select(Media.md5_hash)
.where(Media.media_type != 'screenshot')
.order_by(func.random())
.limit(1)
)
result = await session.execute(stmt)
row = result.fetchone()
if row: async with db.session() as session:
return RedirectResponse(url=f"/view/{row[0]}", status_code=302) if type == "page":
# Get a random page
stmt = select(Page.uri).order_by(func.random()).limit(1)
result = await session.execute(stmt)
row = result.fetchone()
if row:
return RedirectResponse(url=f"/page/{row[0]}", status_code=302)
else: else:
return RedirectResponse(url="/", status_code=302) # Get a random media item (excluding screenshots)
stmt = (
select(Media.md5_hash)
.where(Media.media_type != 'screenshot')
.order_by(func.random())
.limit(1)
)
result = await session.execute(stmt)
row = result.fetchone()
if row:
return RedirectResponse(url=f"/view/{row[0]}", status_code=302)
return RedirectResponse(url="/", status_code=302)
@app.get("/api/search") @app.get("/api/search")