diff --git a/CLAUDE.md b/CLAUDE.md index f244b09..7192490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,10 @@ **Always use `uri`, never `url`.** This applies to variable names, function names, column names, and comments. URI is the correct term (Uniform Resource Identifier). +## Database Rules + +**NEVER write raw SQL strings.** Always use SQLAlchemy ORM with proper model queries. No `text()`, no f-strings with SQL, no string concatenation for queries. Only exception: comments explicitly stating raw SQL is allowed (e.g., for FTS5 virtual tables). + ## Quick Start ```bash diff --git a/archive.py b/archive.py index 7bf4a28..eb30eec 100644 --- a/archive.py +++ b/archive.py @@ -78,10 +78,14 @@ def url_to_path(url: str) -> str: return f"{path}/index.html" -def html_to_markdown(html: str, base_url: str = '') -> str: +def html_to_markdown(html: str, base_url: str = '', trim_wrapper: bool = False) -> str: """Convert HTML to markdown.""" if not HAS_HTML2TEXT: return html + # Optionally strip nav/header/footer/logo before conversion + if trim_wrapper: + from neopig import trim_html_wrapper + html = trim_html_wrapper(html) h = html2text.HTML2Text() h.ignore_links = False h.ignore_images = False @@ -106,12 +110,14 @@ class SiteArchiver: include_markdown: bool = True, screenshot_config: ScreenshotConfig = None, fast_mode: bool = False, + trim_wrapper: bool = False, ): self.output_dir = Path(output_dir) self.include_screenshots = include_screenshots self.include_markdown = include_markdown and HAS_HTML2TEXT self.screenshot_config = screenshot_config or ScreenshotConfig(enabled=include_screenshots) self.fast_mode = fast_mode + self.trim_wrapper = trim_wrapper async def archive( self, @@ -147,9 +153,20 @@ class SiteArchiver: vault_path=vault_path, screenshot_config=self.screenshot_config, fast_mode=self.fast_mode, + trim_wrapper=self.trim_wrapper, ) await pig.init() + # Load previously crawled media/screenshots from DB (source of truth for resume) + crawled_media = await pig.db.get_crawled_media_uris() + crawled_screenshots = await pig.db.get_crawled_screenshot_uris() + logger.info(f"Resume state: {len(crawled_media)} media URIs, {len(crawled_screenshots)} screenshotted pages in DB") + if crawled_media: + pig.seen_media = crawled_media + if crawled_screenshots: + pig.seen_screenshots = crawled_screenshots + logger.info(f"Screenshots enabled: {pig.screenshot_config.enabled}") + # Run the crawl stats = await pig.crawl( target_uri=target_url, @@ -160,107 +177,66 @@ class SiteArchiver: ) # Now package the results - logger.info("Packaging archive...") + logger.info("Packaging archive (streaming mode)...") - with tempfile.TemporaryDirectory() as tmpdir: - archive_root = Path(tmpdir) / archive_name - archive_root.mkdir(parents=True) + tar_path = self.output_dir / f"{archive_name}.tar.gz" + html_vault_path = Path(vault_path) / 'html_vault' / domain + media_vault_path = Path(vault_path) / 'media_vault' / domain + linkpeek_vault_path = Path(vault_path) / 'linkpeek_vault' / domain - # Create subdirectories - html_dir = archive_root / 'html' - media_dir = archive_root / 'media' - html_dir.mkdir() - media_dir.mkdir() + # Get URL-to-hash mapping for rewriting external URLs to local copies + logger.info("Loading media URL mappings...") + url_to_hash = await pig.db.get_all_media_uri_mappings() + logger.info(f"Loaded {len(url_to_hash)} URL mappings for rewriting") - if self.include_markdown: - md_dir = archive_root / 'markdown' - md_dir.mkdir() + def rewrite_urls(html_content: str) -> str: + """Rewrite external image/media URLs to local archive paths.""" + import re + def replace_url(match): + url = match.group(1) + if url in url_to_hash: + md5 = url_to_hash[url] + # Get extension from original URL + ext = Path(url.split('?')[0]).suffix or '.bin' + return match.group(0).replace(url, f'../media/{md5}{ext}') + return match.group(0) - if self.include_screenshots: - screenshots_dir = archive_root / 'screenshots' - screenshots_dir.mkdir() + # Replace src="url" and href="url" patterns + html_content = re.sub(r'src=["\']([^"\']+)["\']', replace_url, html_content) + html_content = re.sub(r'href=["\']([^"\']+\.(jpg|jpeg|png|gif|webp|mp4|webm|svg))["\']', + replace_url, html_content, flags=re.IGNORECASE) + return html_content - # Read pages from neopig's html vault - html_vault_path = Path(vault_path) / 'html_vault' / domain - sitemap = [] + # Step 1: Scan HTML files to build sitemap and search index (no copying) + logger.info("Building search index...") + sitemap = [] + html_contents = {} - if html_vault_path.exists(): - for html_file in html_vault_path.rglob('*.html'): - rel_path = html_file.relative_to(html_vault_path) - html_content = html_file.read_text(encoding='utf-8', errors='replace') + if html_vault_path.exists(): + for html_file in html_vault_path.rglob('*.html'): + rel_path = html_file.relative_to(html_vault_path) + try: + content = html_file.read_text(encoding='utf-8', errors='replace') + # Rewrite external URLs to local copies + content = rewrite_urls(content) + title = self._extract_title(content) or str(rel_path) + sitemap.append({'path': f'html/{rel_path}', 'title': title}) + html_contents[str(rel_path)] = content + except Exception: + pass - # Write HTML - dest_path = html_dir / rel_path - await aiofiles.os.makedirs(dest_path.parent, exist_ok=True) - async with aiofiles.open(dest_path, 'w', encoding='utf-8') as f: - await f.write(html_content) + # Step 2: Create generated files in small temp dir + local_tmpdir = self.output_dir / '.tmp' + local_tmpdir.mkdir(parents=True, exist_ok=True) - # Write markdown - if self.include_markdown: - md_path = md_dir / str(rel_path).replace('.html', '.md') - await aiofiles.os.makedirs(md_path.parent, exist_ok=True) - md_content = html_to_markdown(html_content) - async with aiofiles.open(md_path, 'w', encoding='utf-8') as f: - await f.write(md_content) - - # Extract title for sitemap - title = self._extract_title(html_content) or str(rel_path) - sitemap.append({ - 'path': f'html/{rel_path}', - 'title': title, - }) - - # Copy media from neopig's media vault (follows symlinks to hash vault) - media_vault_path = Path(vault_path) / 'media_vault' / domain - if media_vault_path.exists(): - for media_file in media_vault_path.rglob('*'): - if media_file.is_file() or media_file.is_symlink(): - try: - # Follow symlinks to get actual content - if media_file.is_symlink(): - target = media_file.resolve() - if not target.exists(): - logger.debug(f"Skipping broken symlink: {media_file}") - continue - content = target.read_bytes() - else: - content = media_file.read_bytes() - rel_path = media_file.relative_to(media_vault_path) - dest_path = media_dir / rel_path - await aiofiles.os.makedirs(dest_path.parent, exist_ok=True) - async with aiofiles.open(dest_path, 'wb') as f: - await f.write(content) - except Exception as e: - logger.debug(f"Error copying media {media_file}: {e}") - - # Copy screenshots from neopig's linkpeek vault (follows symlinks to hash vault) - if self.include_screenshots: - linkpeek_vault_path = Path(vault_path) / 'linkpeek_vault' / domain - if linkpeek_vault_path.exists(): - for screenshot_file in linkpeek_vault_path.rglob('*.png'): - try: - # Follow symlinks to get actual content - if screenshot_file.is_symlink(): - target = screenshot_file.resolve() - if not target.exists(): - logger.debug(f"Skipping broken symlink: {screenshot_file}") - continue - content = target.read_bytes() - else: - content = screenshot_file.read_bytes() - rel_path = screenshot_file.relative_to(linkpeek_vault_path) - dest_path = screenshots_dir / rel_path - await aiofiles.os.makedirs(dest_path.parent, exist_ok=True) - async with aiofiles.open(dest_path, 'wb') as f: - await f.write(content) - except Exception as e: - logger.debug(f"Error copying screenshot {screenshot_file}: {e}") + with tempfile.TemporaryDirectory(dir=local_tmpdir) as tmpdir: + tmpdir_path = Path(tmpdir) # Create search database - await self._create_search_database(archive_root, sitemap, domain, html_dir) + await self._create_search_database_streaming(tmpdir_path, sitemap, domain, html_contents) - # Write embedded serve.py - self._write_serve_py(archive_root) + # Write serve.py + self._write_serve_py(tmpdir_path) # Write metadata metadata = { @@ -271,29 +247,195 @@ class SiteArchiver: 'include_screenshots': self.include_screenshots, 'include_markdown': self.include_markdown, } - async with aiofiles.open(archive_root / 'metadata.json', 'w') as f: - await f.write(json.dumps(metadata, indent=2)) + (tmpdir_path / 'metadata.json').write_text(json.dumps(metadata, indent=2)) - # Copy state file into archive for future delta crawls + # Copy state file state_domain = domain.replace('.', '-').replace(':', '-') state_file = Path("data") / f"crawl-state-{state_domain}.json" if state_file.exists(): - shutil.copy(state_file, archive_root / 'crawl_state.json') + shutil.copy(state_file, tmpdir_path / 'crawl_state.json') logger.info(f"Included crawl state for future delta crawls") - # Create tar.gz - tar_path = self.output_dir / f"{archive_name}.tar.gz" + # Write index.html + self._write_index_html(tmpdir_path, sitemap, domain) - def create_tarball(): + # Step 3: Stream everything to tar.gz in one pass + logger.info("Streaming to archive...") + + def stream_to_tar(): with tarfile.open(tar_path, 'w:gz') as tar: - tar.add(archive_root, arcname=archive_name) + # Add generated files first (from temp) + for f in tmpdir_path.iterdir(): + tar.add(f, arcname=f"{archive_name}/{f.name}") - await asyncio.to_thread(create_tarball) + # Stream HTML files (with rewritten URLs) + for rel_path_str, content in html_contents.items(): + try: + html_bytes = content.encode('utf-8') + arcname = f"{archive_name}/html/{rel_path_str}" + info = tarfile.TarInfo(name=arcname) + info.size = len(html_bytes) + tar.addfile(info, io.BytesIO(html_bytes)) - final_size = tar_path.stat().st_size - logger.info(f"Archive complete: {tar_path} ({final_size / 1024 / 1024:.1f} MB)") + # Generate markdown on the fly + if self.include_markdown: + md_content = html_to_markdown(content, trim_wrapper=self.trim_wrapper) + md_bytes = md_content.encode('utf-8') + md_rel = rel_path_str.replace('.html', '.md') + md_arcname = f"{archive_name}/markdown/{md_rel}" + md_info = tarfile.TarInfo(name=md_arcname) + md_info.size = len(md_bytes) + tar.addfile(md_info, io.BytesIO(md_bytes)) + except Exception as e: + logger.debug(f"Error adding HTML {rel_path_str}: {e}") - return tar_path + # Stream media files by hash (matching rewritten URLs) + # Build hash -> file path mapping from hash vault + hash_vault = Path(vault_path) + added_hashes = set() + for url, md5 in url_to_hash.items(): + if md5 in added_hashes: + continue + # Find file in hash vault: vault/xx/hash.ext + bucket = md5[:2] + bucket_dir = hash_vault / bucket + if bucket_dir.exists(): + for f in bucket_dir.iterdir(): + if f.stem == md5: + try: + arcname = f"{archive_name}/media/{f.name}" + tar.add(f, arcname=arcname) + added_hashes.add(md5) + except Exception as e: + logger.debug(f"Error adding media {f}: {e}") + break + + # Stream screenshots (follow symlinks) + if self.include_screenshots and linkpeek_vault_path.exists(): + for screenshot_file in linkpeek_vault_path.rglob('*.png'): + try: + rel_path = screenshot_file.relative_to(linkpeek_vault_path) + arcname = f"{archive_name}/screenshots/{rel_path}" + if screenshot_file.is_symlink(): + target = screenshot_file.resolve() + if target.exists(): + tar.add(target, arcname=arcname) + else: + tar.add(screenshot_file, arcname=arcname) + except Exception as e: + logger.debug(f"Error adding screenshot {screenshot_file}: {e}") + + # Bundle neopig source files for self-contained crawling + neopig_src = Path(__file__).parent + neopig_files = [ + 'neopig.py', 'database.py', 'async_web_fetcher.py', + 'storage.py', 'domain_vault.py', 'screenshot.py', + 'html2md.py', 'serp.py', 'filevault.py', 'async_filevault.py', + ] + for pyfile in neopig_files: + src_path = neopig_src / pyfile + if src_path.exists(): + tar.add(src_path, arcname=f"{archive_name}/neopig/{pyfile}") + + # Add requirements.txt for neopig dependencies + req_path = neopig_src / 'requirements.txt' + if req_path.exists(): + tar.add(req_path, arcname=f"{archive_name}/requirements.txt") + + await asyncio.to_thread(stream_to_tar) + + # Clear html_contents to free memory + html_contents.clear() + + final_size = tar_path.stat().st_size + logger.info(f"Archive complete: {tar_path} ({final_size / 1024 / 1024:.1f} MB)") + + return tar_path + + async def _create_search_database_streaming(self, tmpdir: Path, sitemap: list, domain: str, html_contents: dict): + """Create FTS5 search database from collected html contents.""" + import sqlite3 + + db_path = tmpdir / 'archive.db' + + def create_db(): + conn = sqlite3.connect(db_path) + c = conn.cursor() + + c.execute(''' + CREATE TABLE pages ( + id INTEGER PRIMARY KEY, + path TEXT UNIQUE, + title TEXT, + content TEXT + ) + ''') + c.execute(''' + CREATE VIRTUAL TABLE pages_fts USING fts5( + title, content, path, + content='pages', + content_rowid='id' + ) + ''') + + for item in sitemap: + html_path = item['path'].replace('html/', '', 1) + content = html_contents.get(html_path, '') + if content: + try: + soup = BeautifulSoup(content, 'html.parser') + for tag in soup(['script', 'style', 'nav', 'header', 'footer']): + tag.decompose() + text = soup.get_text(separator=' ', strip=True)[:50000] + except Exception: + text = '' + else: + text = '' + + try: + c.execute('INSERT INTO pages (path, title, content) VALUES (?, ?, ?)', + (item['path'], item['title'], text)) + except Exception: + pass + + c.execute(''' + INSERT INTO pages_fts(rowid, title, content, path) + SELECT id, title, content, path FROM pages + ''') + + conn.commit() + conn.close() + + await asyncio.to_thread(create_db) + + def _write_index_html(self, tmpdir: Path, sitemap: list, domain: str): + """Write index.html with sitemap.""" + html = f''' + +
+{len(sitemap)} pages archived
+Hydrate media from the web
@@ -961,11 +923,23 @@ LIVE_HTML = """ 🐷 neopig Search Live + Random Crawl PhantomWatch images appear as they're crawled
@@ -1047,10 +1021,11 @@ LIVE_HTML = """ const grid = document.getElementById('grid'); - // Find new items + // Find new items (on first poll, show all; after that only new ones) + const isFirstPoll = seenHashes.size === 0; const newItems = media.filter(m => !seenHashes.has(m.md5_hash)); - // Add new items to the top + // Add new items to the top (or all items on first load) newItems.reverse().forEach(item => { seenHashes.add(item.md5_hash); newCount++; @@ -1115,24 +1090,25 @@ async def live_page(): @app.get("/view/{md5_hash}", response_class=HTMLResponse) async def view_media_page(md5_hash: str): """Detail view page for a single media item.""" - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row - - cursor = await db.execute("SELECT * FROM media WHERE md5_hash = ?", (md5_hash,)) - media = await cursor.fetchone() + async with db.session() as session: + result = await session.execute( + text("SELECT * FROM media WHERE md5_hash = :hash"), + {'hash': md5_hash} + ) + media = result.fetchone() if not media: raise HTTPException(status_code=404, detail="Media not found") - cursor = await db.execute( - """SELECT media_uri, page_uri, page_title, page_content, + result = await session.execute( + text("""SELECT media_uri, page_uri, page_title, page_content, detail_page_uri, detail_title, detail_content, discovered_at - FROM media_sources WHERE md5_hash = ?""", - (md5_hash,) + FROM media_sources WHERE md5_hash = :hash"""), + {'hash': md5_hash} ) - sources = await cursor.fetchall() + sources = result.fetchall() - media = dict(media) - sources = [dict(s) for s in sources] + media = dict(media._mapping) + sources = [dict(s._mapping) for s in sources] keywords = json.loads(media.get('keywords') or '[]') # Generate download filename @@ -1156,6 +1132,13 @@ async def view_media_page(md5_hash: str): ext = ext_map.get(media.get('mime_type', ''), '.bin') download_filename = f"{download_name}{ext}" + # Display title: alt_text -> title -> page_title -> hash + display_title = media.get('alt_text') or media.get('title') + if not display_title and sources: + display_title = sources[0].get('page_title') + if not display_title: + display_title = f"Media {md5_hash[:12]}" + is_video = media['media_type'] == 'video' is_audio = media['media_type'] == 'audio' @@ -1191,95 +1174,69 @@ async def view_media_page(md5_hash: str): page_content_html = "" if page_uri: - async with aiosqlite.connect(DB_PATH) as db2: - db2.row_factory = aiosqlite.Row - cursor = await db2.execute( - "SELECT title, content, markdown, raw_html FROM pages WHERE uri = ?", - (page_uri,) - ) - page_row = await cursor.fetchone() + page_row = await db.get_page_by_uri(page_uri) - if page_row: - import html as html_module - page_title = page_row["title"] or "" + if page_row: + import html as html_module + import re + page_title = page_row.get("title") or "" - if page_row["markdown"]: - # Render markdown to HTML - try: - import markdown - import re - md = markdown.Markdown(extensions=['fenced_code', 'tables', 'nl2br']) - rendered = md.convert(page_row["markdown"][:100000]) + # Render markdown to HTML with our stylesheet + if page_row.get("markdown"): + # Render markdown to HTML + try: + import markdown + md_converter = markdown.Markdown(extensions=['fenced_code', 'tables', 'nl2br']) + rendered = md_converter.convert(page_row["markdown"][:100000]) - # Hydrate: rewrite image URLs to use our vault - # Find all img src URLs - img_pattern = re.compile(r'{escaped}{p.replace(chr(10), "
")}
{p.replace(chr(10), "
")}
{html_module.escape(page.get('markdown', '')[:50000])}"
+ elif page.get("content"):
+ escaped = html_module.escape(page["content"][:50000])
+ content_html = f"{escaped}"
- # Replace original URLs with vault paths
- for orig_url, resolved_url in resolved_urls.items():
- if resolved_url in resolved_to_hash:
- md5 = resolved_to_hash[resolved_url]
- content_html = content_html.replace(f'src="{orig_url}"', f'src="/media/{md5}"')
- content_html = content_html.replace(f"src='{orig_url}'", f'src="/media/{md5}"')
- except ImportError:
- content_html = f"{html_module.escape(page.get('markdown', '')[:50000])}"
- elif page.get("content"):
- escaped = html_module.escape(page["content"][:50000])
- content_html = f"{escaped}"
+ # Find screenshot
+ screenshot_html = ""
+ screenshot_hash = await db.get_page_screenshot(uri)
+ if screenshot_hash:
+ screenshot_html = f'''
+ No content available
'}No content available
'}Export archived pages as a static site with local media
@@ -2224,38 +2512,43 @@ async def health(): @app.get("/api/stats") -async def get_stats(): +async def get_stats_endpoint(): """Get database statistics.""" - async with aiosqlite.connect(DB_PATH) as db: - stats = {} + stats = await db.get_stats() + # Add page count (handled separately since table may not exist) + try: + from sqlalchemy import select, func + from database import Page + async with db.session() as session: + result = await session.execute(select(func.count()).select_from(Page)) + stats['total_pages'] = result.scalar() or 0 + except Exception: + stats['total_pages'] = 0 + return stats - cursor = await db.execute("SELECT COUNT(*) FROM media") - stats['total_media'] = (await cursor.fetchone())[0] - cursor = await db.execute( - "SELECT media_type, COUNT(*) FROM media GROUP BY media_type" +@app.get("/random") +async def random_media(): + """Redirect to a random media item.""" + from sqlalchemy import select, func + from database import Media + import random + + async with db.session() as session: + # Get a random media item (excluding screenshots) + stmt = ( + select(Media.md5_hash) + .where(Media.media_type != 'screenshot') + .order_by(func.random()) + .limit(1) ) - stats['by_type'] = {row[0]: row[1] for row in await cursor.fetchall()} + result = await session.execute(stmt) + row = result.fetchone() - cursor = await db.execute( - "SELECT analysis_status, COUNT(*) FROM media GROUP BY analysis_status" - ) - stats['by_analysis'] = {row[0]: row[1] for row in await cursor.fetchall()} - - cursor = await db.execute("SELECT COUNT(*) FROM media_sources") - stats['total_sources'] = (await cursor.fetchone())[0] - - cursor = await db.execute("SELECT COUNT(*) FROM crawl_jobs") - stats['total_jobs'] = (await cursor.fetchone())[0] - - # Page count (table may not exist in older databases) - try: - cursor = await db.execute("SELECT COUNT(*) FROM pages") - stats['total_pages'] = (await cursor.fetchone())[0] - except Exception: - stats['total_pages'] = 0 - - return stats + if row: + return RedirectResponse(url=f"/view/{row[0]}", status_code=302) + else: + return RedirectResponse(url="/", status_code=302) @app.get("/api/search") @@ -2271,67 +2564,17 @@ async def search( Searches across: keywords, alt_text, title, source URLs, analysis results. """ - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row - - # Build query - conditions = [] - params = [] - - if q: - # Use word boundary matching: space-word-space, start-word-space, space-word-end - # This prevents "car" from matching "card" - word_patterns = [ - f"% {q} %", # word in middle - f"{q} %", # word at start - f"% {q}", # word at end - q, # exact match - ] - # Build OR conditions for each field with word boundary patterns - field_conditions = [] - for field in ['m.keywords', 'm.alt_text', 'm.title', 'm.analysis_result']: - field_conditions.append(f"({field} LIKE ? OR {field} LIKE ? OR {field} LIKE ? OR {field} = ?)") - params.extend(word_patterns) - - # Also search in media_sources with word boundaries - ms_fields = ['ms.media_uri', 'ms.page_uri', 'ms.page_title', 'ms.page_description', 'ms.page_keywords'] - ms_conditions = [] - for field in ms_fields: - ms_conditions.append(f"({field} LIKE ? OR {field} LIKE ? OR {field} LIKE ? OR {field} = ?)") - params.extend(word_patterns) - - conditions.append(f"""( - {' OR '.join(field_conditions)} OR - EXISTS (SELECT 1 FROM media_sources ms WHERE ms.md5_hash = m.md5_hash AND ({' OR '.join(ms_conditions)})) - )""") - - if type: - conditions.append("m.media_type = ?") - params.append(type) - - if status: - conditions.append("m.analysis_status = ?") - params.append(status) - - where_clause = " AND ".join(conditions) if conditions else "1=1" - - query = f""" - SELECT m.* - FROM media m - WHERE {where_clause} - ORDER BY m.first_seen_at DESC - LIMIT ? OFFSET ? - """ - params.extend([limit, offset]) - - cursor = await db.execute(query, params) - rows = await cursor.fetchall() - - return [dict(row) for row in rows] + results = await db.search_media_advanced( + q=q if q else None, + media_type=type, + limit=limit, + offset=offset + ) + return results @app.get("/api/search/pages") -async def search_pages( +async def search_pages_endpoint( q: str = Query("", description="Search query"), limit: int = Query(50, le=500), ): @@ -2340,70 +2583,18 @@ async def search_pages( """ if not q: return [] - - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row - results = [] - - # Try FTS5 with prefix matching - try: - fts_query = ' '.join(f'"{word}"*' for word in q.split()) - cursor = await db.execute(""" - SELECT p.uri, p.path, p.title, - snippet(pages_fts, 1, '', '', '...', 40) as snippet - FROM pages_fts - JOIN pages p ON pages_fts.rowid = p.id - WHERE pages_fts MATCH ? - ORDER BY rank - LIMIT ? - """, (fts_query, limit)) - results = [dict(row) for row in await cursor.fetchall()] - except Exception: - pass - - # Fallback to LIKE - if not results: - try: - like_q = f'%{q}%' - cursor = await db.execute(""" - SELECT uri, path, title, substr(content, 1, 200) as snippet - FROM pages - WHERE title LIKE ? COLLATE NOCASE - OR content LIKE ? COLLATE NOCASE - LIMIT ? - """, (like_q, like_q, limit)) - results = [dict(row) for row in await cursor.fetchall()] - except Exception: - pass - - return results + return await db.search_pages(q, limit) @app.get("/api/media/{md5_hash}") async def get_media_info(md5_hash: str): """Get full media info including all source URLs.""" - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row + media = await db.get_media_by_hash(md5_hash) + if not media: + raise HTTPException(status_code=404, detail="Media not found") - # Get media record - cursor = await db.execute( - "SELECT * FROM media WHERE md5_hash = ?", - (md5_hash,) - ) - media = await cursor.fetchone() - if not media: - raise HTTPException(status_code=404, detail="Media not found") - - result = dict(media) - - # Get all sources - cursor = await db.execute( - "SELECT media_uri, page_uri, page_title, page_description, page_keywords, alt_text, link_text, discovered_at FROM media_sources WHERE md5_hash = ?", - (md5_hash,) - ) - result['sources'] = [dict(row) for row in await cursor.fetchall()] - - return result + media['sources'] = await db.get_media_sources(md5_hash) + return media def slugify(text: str, max_len: int = 60) -> str: @@ -2419,10 +2610,11 @@ def slugify(text: str, max_len: int = 60) -> str: @app.get("/media/{md5_hash}") -async def serve_media(md5_hash: str): +async def serve_media(md5_hash: str, download: bool = False): """ Serve media file from vault. + Use ?download=1 for attachment mode with smart filename. Caddy should be configured to cache these responses. """ # Find file in vault @@ -2439,31 +2631,30 @@ async def serve_media(md5_hash: str): # Get metadata for filename generation filename = None - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT mime_type, alt_text, title FROM media WHERE md5_hash = ?", - (md5_hash,) - ) - row = await cursor.fetchone() - if row: - if not mime_type and row["mime_type"]: - mime_type = row["mime_type"] - # Generate filename from alt_text or title - name_source = row["alt_text"] or row["title"] - if name_source: - slug = slugify(name_source) + media_record = await db.get_media_by_hash(md5_hash) + if media_record: + if not mime_type and media_record.get("mime_type"): + mime_type = media_record["mime_type"] + # Generate filename from alt_text or title + name_source = media_record.get("alt_text") or media_record.get("title") + if name_source: + slug = slugify(name_source) + if slug: + filename = f"{slug}{ext}" + + # Fallback: try to get page_title from media_sources + if not filename: + sources = await db.get_media_sources(md5_hash) + if sources: + row2 = sources[0] + # Try page_title + hash index + if row2.get("page_title"): + media_idx = int(md5_hash[:4], 16) + slug = slugify(f"{row2['page_title']}-{media_idx}") if slug: filename = f"{slug}{ext}" - - # Fallback: try to get original filename from media_uri - if not filename: - cursor2 = await db.execute( - "SELECT media_uri FROM media_sources WHERE md5_hash = ? LIMIT 1", - (md5_hash,) - ) - row2 = await cursor2.fetchone() - if row2 and row2["media_uri"]: + # Fallback: original filename from URL + if not filename and row2.get("media_uri"): from urllib.parse import urlparse, unquote parsed = urlparse(row2["media_uri"]) orig_name = Path(unquote(parsed.path)).name @@ -2477,16 +2668,29 @@ async def serve_media(md5_hash: str): if not filename: filename = f"{md5_hash[:12]}{ext}" - return FileResponse( - f, - media_type=mime_type, - filename=filename, - content_disposition_type="inline", - headers={ - "Cache-Control": "public, max-age=31536000, immutable", - "X-Content-Hash": md5_hash, - } - ) + # Download mode: attachment with smart filename + # Inline mode: no filename header, browser shows inline + if download: + return FileResponse( + f, + media_type=mime_type, + filename=filename, + content_disposition_type="attachment", + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "X-Content-Hash": md5_hash, + } + ) + else: + return FileResponse( + f, + media_type=mime_type, + content_disposition_type="inline", + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "X-Content-Hash": md5_hash, + } + ) raise HTTPException(status_code=404, detail="Media not found") @@ -2496,35 +2700,18 @@ async def serve_media(md5_hash: str): # ============================================================================ @app.get("/api/crawl/jobs") -async def get_crawl_jobs(limit: int = Query(50, le=200)): +async def get_crawl_jobs_endpoint(limit: int = Query(50, le=200)): """Get recent crawl jobs.""" - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - """ - SELECT * FROM crawl_jobs - ORDER BY started_at DESC - LIMIT ? - """, - (limit,) - ) - rows = await cursor.fetchall() - return [dict(row) for row in rows] + return await db.get_crawl_jobs(limit) @app.get("/api/crawl/jobs/{job_id}") -async def get_crawl_job(job_id: int): +async def get_crawl_job_endpoint(job_id: int): """Get a specific crawl job.""" - async with aiosqlite.connect(DB_PATH) as db: - db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT * FROM crawl_jobs WHERE id = ?", - (job_id,) - ) - row = await cursor.fetchone() - if not row: - raise HTTPException(status_code=404, detail="Job not found") - return dict(row) + job = await db.get_crawl_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job @app.post("/api/crawl") @@ -2558,17 +2745,8 @@ async def start_crawl(request: CrawlRequest, background_tasks: BackgroundTasks): # Create a job for each target for target_uri in targets: - async with aiosqlite.connect(DB_PATH) as db: - cursor = await db.execute( - """ - INSERT INTO crawl_jobs (target_uri, keywords, mode, status, started_at) - VALUES (?, ?, ?, 'running', datetime('now')) - """, - (target_uri, json.dumps(request.keywords), request.mode) - ) - await db.commit() - job_id = cursor.lastrowid - job_ids.append(job_id) + job_id = await db.create_crawl_job(target_uri, request.keywords, request.mode) + job_ids.append(job_id) # Run crawl in background (closure captures job_id and target_uri) async def run_crawl(jid=job_id, uri=target_uri): @@ -2594,29 +2772,11 @@ async def start_crawl(request: CrawlRequest, background_tasks: BackgroundTasks): ) # Update job as completed - async with aiosqlite.connect(DB_PATH) as db: - await db.execute( - """ - UPDATE crawl_jobs - SET status = 'completed', completed_at = datetime('now'), stats = ? - WHERE id = ? - """, - (json.dumps(stats), jid) - ) - await db.commit() + await db.complete_crawl_job(jid, stats) except Exception as e: logger.error(f"Crawl job {jid} failed: {e}") - async with aiosqlite.connect(DB_PATH) as db: - await db.execute( - """ - UPDATE crawl_jobs - SET status = 'failed', completed_at = datetime('now'), stats = ? - WHERE id = ? - """, - (json.dumps({"error": str(e)}), jid) - ) - await db.commit() + await db.complete_crawl_job(jid, {"error": str(e), "status": "failed"}) # Schedule background task background_tasks.add_task(asyncio.create_task, run_crawl()) @@ -2630,8 +2790,8 @@ def main(): parser = argparse.ArgumentParser(description="neopig SERP") parser.add_argument("--port", type=int, default=8000) parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--db", default="neopig.db") - parser.add_argument("--vault", default="vault") + parser.add_argument("--db", default="data/neopig.db") + parser.add_argument("--vault", default="data/vault") args = parser.parse_args()