import json import sqlite3 from pathlib import Path from datetime import datetime from utils.logger import logger class MetadataDB: """ Metadata storage layer for crawler state tracking. Stores: - crawled pages - crawl status - markdown outputs - assets - hashes - timestamps - incremental update info """ def __init__( self, db_path="data/metadata.db" ): self.db_path = Path(db_path) self.db_path.parent.mkdir( parents=True, exist_ok=True ) self.conn = sqlite3.connect( self.db_path ) self.conn.row_factory = sqlite3.Row self.initialize() def initialize(self): """ Create required tables. """ cursor = self.conn.cursor() # Pages table cursor.execute(""" CREATE TABLE IF NOT EXISTS pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE, title TEXT, markdown_path TEXT, status TEXT, status_code INTEGER, content_hash TEXT, word_count INTEGER, markdown_length INTEGER, crawled_at TEXT, updated_at TEXT, parent_url TEXT, depth INTEGER, metadata_json TEXT ) """) # Assets table cursor.execute(""" CREATE TABLE IF NOT EXISTS assets ( id INTEGER PRIMARY KEY AUTOINCREMENT, page_url TEXT, asset_url TEXT, local_path TEXT, downloaded_at TEXT ) """) # Crawl queue state cursor.execute(""" CREATE TABLE IF NOT EXISTS crawl_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE, status TEXT, discovered_at TEXT ) """) # Failed pages cursor.execute(""" CREATE TABLE IF NOT EXISTS failures ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT, error_message TEXT, failed_at TEXT ) """) self.conn.commit() logger.info( "Metadata database initialized." ) def save_page( self, url, title="", markdown_path="", status="success", status_code=200, content_hash="", word_count=0, markdown_length=0, parent_url=None, depth=0, metadata=None ): """ Insert or update page metadata. """ cursor = self.conn.cursor() now = datetime.utcnow().isoformat() metadata_json = json.dumps( metadata or {}, ensure_ascii=False ) cursor.execute(""" INSERT INTO pages ( url, title, markdown_path, status, status_code, content_hash, word_count, markdown_length, crawled_at, updated_at, parent_url, depth, metadata_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET title=excluded.title, markdown_path=excluded.markdown_path, status=excluded.status, status_code=excluded.status_code, content_hash=excluded.content_hash, word_count=excluded.word_count, markdown_length=excluded.markdown_length, updated_at=excluded.updated_at, metadata_json=excluded.metadata_json """, ( url, title, markdown_path, status, status_code, content_hash, word_count, markdown_length, now, now, parent_url, depth, metadata_json )) self.conn.commit() logger.info( f"Saved page metadata: {url}" ) def save_asset( self, page_url, asset_url, local_path ): """ Save asset metadata. """ cursor = self.conn.cursor() cursor.execute(""" INSERT INTO assets ( page_url, asset_url, local_path, downloaded_at ) VALUES (?, ?, ?, ?) """, ( page_url, asset_url, local_path, datetime.utcnow().isoformat() )) self.conn.commit() def save_failure( self, url, error_message ): """ Save failed crawl. """ cursor = self.conn.cursor() cursor.execute(""" INSERT INTO failures ( url, error_message, failed_at ) VALUES (?, ?, ?) """, ( url, error_message, datetime.utcnow().isoformat() )) self.conn.commit() logger.warning( f"Saved failure: {url}" ) def enqueue_url( self, url, status="pending" ): """ Save crawl queue item. """ cursor = self.conn.cursor() cursor.execute(""" INSERT OR IGNORE INTO crawl_queue ( url, status, discovered_at ) VALUES (?, ?, ?) """, ( url, status, datetime.utcnow().isoformat() )) self.conn.commit() def update_queue_status( self, url, status ): """ Update queue item status. """ cursor = self.conn.cursor() cursor.execute(""" UPDATE crawl_queue SET status=? WHERE url=? """, ( status, url )) self.conn.commit() def page_exists( self, url ): """ Check if page already crawled. """ cursor = self.conn.cursor() cursor.execute(""" SELECT 1 FROM pages WHERE url=? LIMIT 1 """, (url,)) row = cursor.fetchone() return row is not None def get_page( self, url ): """ Retrieve page metadata. """ cursor = self.conn.cursor() cursor.execute(""" SELECT * FROM pages WHERE url=? """, (url,)) row = cursor.fetchone() if not row: return None return dict(row) def get_all_pages(self): """ Get all crawled pages. """ cursor = self.conn.cursor() cursor.execute(""" SELECT * FROM pages ORDER BY crawled_at DESC """) rows = cursor.fetchall() return [ dict(row) for row in rows ] def get_failed_pages(self): """ Get all failures. """ cursor = self.conn.cursor() cursor.execute(""" SELECT * FROM failures ORDER BY failed_at DESC """) rows = cursor.fetchall() return [ dict(row) for row in rows ] def get_pending_queue(self): """ Get pending URLs. """ cursor = self.conn.cursor() cursor.execute(""" SELECT * FROM crawl_queue WHERE status='pending' """) rows = cursor.fetchall() return [ dict(row) for row in rows ] def delete_page( self, url ): """ Remove page metadata. """ cursor = self.conn.cursor() cursor.execute(""" DELETE FROM pages WHERE url=? """, (url,)) self.conn.commit() def clear_failures(self): """ Clear failure table. """ cursor = self.conn.cursor() cursor.execute(""" DELETE FROM failures """) self.conn.commit() def export_pages_json( self, output_path ): """ Export all pages metadata. """ pages = self.get_all_pages() with open( output_path, "w", encoding="utf-8" ) as f: json.dump( pages, f, ensure_ascii=False, indent=2 ) logger.info( f"Exported metadata JSON: " f"{output_path}" ) def close(self): """ Close database connection. """ self.conn.close() logger.info( "Metadata database closed." )