reddit-universal-scraper/export/database.py
Sanjeev Kumar f65b35f881 feat: Add long-running observability and integration features
- Job history tracking with SQLite table
- Dry-run mode (--dry-run) to test scrape rules
- Plugin system with 3 built-in plugins (sentiment, dedupe, keywords)
- REST API server (--api) for Metabase/Grafana integration
- Parquet export (--export-parquet) for DuckDB/warehouses
- SQLite maintenance (--backup, --vacuum)
- Dashboard Integrations tab with external tools guides
- Updated Dockerfile and docker-compose.yml for cloud deployment
- Comprehensive README documentation
2025-12-14 03:42:24 +05:30

642 lines
19 KiB
Python

"""
Database module - SQLite storage for scraped data
"""
import sqlite3
from pathlib import Path
from datetime import datetime
import json
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import DB_PATH, DATA_DIR
def get_connection():
"""Get database connection."""
DATA_DIR.mkdir(exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_database():
"""Initialize database tables."""
conn = get_connection()
cursor = conn.cursor()
# Posts table
cursor.execute("""
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
subreddit TEXT,
title TEXT,
author TEXT,
created_utc TEXT,
permalink TEXT UNIQUE,
url TEXT,
score INTEGER DEFAULT 0,
upvote_ratio REAL DEFAULT 0,
num_comments INTEGER DEFAULT 0,
num_crossposts INTEGER DEFAULT 0,
selftext TEXT,
post_type TEXT,
is_nsfw BOOLEAN DEFAULT 0,
is_spoiler BOOLEAN DEFAULT 0,
flair TEXT,
total_awards INTEGER DEFAULT 0,
has_media BOOLEAN DEFAULT 0,
media_downloaded BOOLEAN DEFAULT 0,
source TEXT,
scraped_at TEXT DEFAULT CURRENT_TIMESTAMP,
sentiment_score REAL,
sentiment_label TEXT
)
""")
# Comments table
cursor.execute("""
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment_id TEXT UNIQUE,
post_id TEXT,
post_permalink TEXT,
parent_id TEXT,
author TEXT,
body TEXT,
score INTEGER DEFAULT 0,
created_utc TEXT,
depth INTEGER DEFAULT 0,
is_submitter BOOLEAN DEFAULT 0,
scraped_at TEXT DEFAULT CURRENT_TIMESTAMP,
sentiment_score REAL,
sentiment_label TEXT,
FOREIGN KEY (post_id) REFERENCES posts(id)
)
""")
# Subreddits table (for tracking)
cursor.execute("""
CREATE TABLE IF NOT EXISTS subreddits (
name TEXT PRIMARY KEY,
last_scraped TEXT,
total_posts INTEGER DEFAULT 0,
total_comments INTEGER DEFAULT 0,
total_media INTEGER DEFAULT 0
)
""")
# Scheduled jobs table
cursor.execute("""
CREATE TABLE IF NOT EXISTS scheduled_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target TEXT,
is_user BOOLEAN DEFAULT 0,
mode TEXT DEFAULT 'full',
limit_posts INTEGER DEFAULT 100,
cron_expression TEXT,
last_run TEXT,
next_run TEXT,
enabled BOOLEAN DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
# Alerts table
cursor.execute("""
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT,
subreddit TEXT,
alert_type TEXT DEFAULT 'discord',
webhook_url TEXT,
enabled BOOLEAN DEFAULT 1,
last_triggered TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
# Job history table for observability
cursor.execute("""
CREATE TABLE IF NOT EXISTS job_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT UNIQUE,
target TEXT,
is_user BOOLEAN DEFAULT 0,
mode TEXT,
status TEXT,
started_at TEXT,
completed_at TEXT,
duration_seconds REAL,
posts_scraped INTEGER DEFAULT 0,
comments_scraped INTEGER DEFAULT 0,
media_downloaded INTEGER DEFAULT 0,
errors TEXT,
error_count INTEGER DEFAULT 0,
dry_run BOOLEAN DEFAULT 0
)
""")
# Create indexes
cursor.execute("CREATE INDEX IF NOT EXISTS idx_posts_subreddit ON posts(subreddit)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_utc)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_posts_score ON posts(score)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_comments_author ON comments(author)")
conn.commit()
conn.close()
print("✅ Database initialized")
def save_post(post_data, subreddit):
"""Save a single post to database."""
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("""
INSERT OR REPLACE INTO posts
(id, subreddit, title, author, created_utc, permalink, url, score,
upvote_ratio, num_comments, num_crossposts, selftext, post_type,
is_nsfw, is_spoiler, flair, total_awards, has_media, media_downloaded, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
post_data.get('id'),
subreddit,
post_data.get('title'),
post_data.get('author'),
post_data.get('created_utc'),
post_data.get('permalink'),
post_data.get('url'),
post_data.get('score', 0),
post_data.get('upvote_ratio', 0),
post_data.get('num_comments', 0),
post_data.get('num_crossposts', 0),
post_data.get('selftext', ''),
post_data.get('post_type'),
post_data.get('is_nsfw', False),
post_data.get('is_spoiler', False),
post_data.get('flair', ''),
post_data.get('total_awards', 0),
post_data.get('has_media', False),
post_data.get('media_downloaded', False),
post_data.get('source', '')
))
conn.commit()
return True
except Exception as e:
print(f"DB Error: {e}")
return False
finally:
conn.close()
def save_posts_batch(posts, subreddit):
"""Save multiple posts efficiently."""
conn = get_connection()
cursor = conn.cursor()
saved = 0
for post in posts:
try:
cursor.execute("""
INSERT OR IGNORE INTO posts
(id, subreddit, title, author, created_utc, permalink, url, score,
upvote_ratio, num_comments, num_crossposts, selftext, post_type,
is_nsfw, is_spoiler, flair, total_awards, has_media, media_downloaded, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
post.get('id'),
subreddit,
post.get('title'),
post.get('author'),
post.get('created_utc'),
post.get('permalink'),
post.get('url'),
post.get('score', 0),
post.get('upvote_ratio', 0),
post.get('num_comments', 0),
post.get('num_crossposts', 0),
post.get('selftext', ''),
post.get('post_type'),
post.get('is_nsfw', False),
post.get('is_spoiler', False),
post.get('flair', ''),
post.get('total_awards', 0),
post.get('has_media', False),
post.get('media_downloaded', False),
post.get('source', '')
))
if cursor.rowcount > 0:
saved += 1
except:
continue
conn.commit()
conn.close()
return saved
def save_comments_batch(comments, post_id):
"""Save multiple comments efficiently."""
conn = get_connection()
cursor = conn.cursor()
saved = 0
for comment in comments:
try:
cursor.execute("""
INSERT OR IGNORE INTO comments
(comment_id, post_id, post_permalink, parent_id, author, body,
score, created_utc, depth, is_submitter)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
comment.get('comment_id'),
post_id,
comment.get('post_permalink'),
comment.get('parent_id'),
comment.get('author'),
comment.get('body'),
comment.get('score', 0),
comment.get('created_utc'),
comment.get('depth', 0),
comment.get('is_submitter', False)
))
if cursor.rowcount > 0:
saved += 1
except:
continue
conn.commit()
conn.close()
return saved
def search_posts(query=None, subreddit=None, author=None, min_score=None,
start_date=None, end_date=None, post_type=None, limit=100):
"""Search posts with filters."""
conn = get_connection()
cursor = conn.cursor()
sql = "SELECT * FROM posts WHERE 1=1"
params = []
if query:
sql += " AND (title LIKE ? OR selftext LIKE ?)"
params.extend([f"%{query}%", f"%{query}%"])
if subreddit:
sql += " AND subreddit = ?"
params.append(subreddit)
if author:
sql += " AND author = ?"
params.append(author)
if min_score:
sql += " AND score >= ?"
params.append(min_score)
if start_date:
sql += " AND created_utc >= ?"
params.append(start_date)
if end_date:
sql += " AND created_utc <= ?"
params.append(end_date)
if post_type:
sql += " AND post_type = ?"
params.append(post_type)
sql += " ORDER BY created_utc DESC LIMIT ?"
params.append(limit)
cursor.execute(sql, params)
results = [dict(row) for row in cursor.fetchall()]
conn.close()
return results
def search_comments(query=None, post_id=None, author=None, min_score=None, limit=100):
"""Search comments with filters."""
conn = get_connection()
cursor = conn.cursor()
sql = "SELECT * FROM comments WHERE 1=1"
params = []
if query:
sql += " AND body LIKE ?"
params.append(f"%{query}%")
if post_id:
sql += " AND post_id = ?"
params.append(post_id)
if author:
sql += " AND author = ?"
params.append(author)
if min_score:
sql += " AND score >= ?"
params.append(min_score)
sql += " ORDER BY score DESC LIMIT ?"
params.append(limit)
cursor.execute(sql, params)
results = [dict(row) for row in cursor.fetchall()]
conn.close()
return results
def get_subreddit_stats(subreddit):
"""Get statistics for a subreddit."""
conn = get_connection()
cursor = conn.cursor()
stats = {}
# Post stats
cursor.execute("""
SELECT
COUNT(*) as total_posts,
AVG(score) as avg_score,
MAX(score) as max_score,
SUM(num_comments) as total_comments,
AVG(upvote_ratio) as avg_upvote_ratio
FROM posts WHERE subreddit = ?
""", (subreddit,))
row = cursor.fetchone()
if row:
stats.update(dict(row))
# Post type distribution
cursor.execute("""
SELECT post_type, COUNT(*) as count
FROM posts WHERE subreddit = ?
GROUP BY post_type
""", (subreddit,))
stats['post_types'] = {row['post_type']: row['count'] for row in cursor.fetchall()}
# Top authors
cursor.execute("""
SELECT author, COUNT(*) as post_count, SUM(score) as total_score
FROM posts WHERE subreddit = ? AND author != '[deleted]'
GROUP BY author ORDER BY post_count DESC LIMIT 10
""", (subreddit,))
stats['top_authors'] = [dict(row) for row in cursor.fetchall()]
# Activity by hour
cursor.execute("""
SELECT strftime('%H', created_utc) as hour, COUNT(*) as count
FROM posts WHERE subreddit = ?
GROUP BY hour ORDER BY hour
""", (subreddit,))
stats['hourly_activity'] = {row['hour']: row['count'] for row in cursor.fetchall()}
conn.close()
return stats
def get_all_subreddits():
"""Get list of all scraped subreddits."""
conn = get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT subreddit, COUNT(*) as post_count,
MAX(created_utc) as latest_post,
MIN(created_utc) as oldest_post
FROM posts GROUP BY subreddit ORDER BY post_count DESC
""")
results = [dict(row) for row in cursor.fetchall()]
conn.close()
return results
# --- JOB HISTORY FUNCTIONS ---
def start_job_record(target, mode, is_user=False, dry_run=False):
"""
Start tracking a new scrape job.
Returns:
job_id: Unique identifier for the job
"""
import uuid
conn = get_connection()
cursor = conn.cursor()
job_id = str(uuid.uuid4())[:8]
started_at = datetime.now().isoformat()
cursor.execute("""
INSERT INTO job_history (job_id, target, is_user, mode, status, started_at, dry_run)
VALUES (?, ?, ?, ?, 'running', ?, ?)
""", (job_id, target, is_user, mode, started_at, dry_run))
conn.commit()
conn.close()
print(f"📋 Job started: {job_id}")
return job_id
def complete_job_record(job_id, status, posts=0, comments=0, media=0, errors=None):
"""
Complete a job record with results.
Args:
job_id: Job ID from start_job_record
status: 'completed' or 'failed'
posts: Number of posts scraped
comments: Number of comments scraped
media: Number of media files downloaded
errors: Error message if failed
"""
conn = get_connection()
cursor = conn.cursor()
completed_at = datetime.now().isoformat()
# Calculate duration
cursor.execute("SELECT started_at FROM job_history WHERE job_id = ?", (job_id,))
row = cursor.fetchone()
duration = 0
error_count = 0
if row:
started = datetime.fromisoformat(row['started_at'])
duration = (datetime.now() - started).total_seconds()
if errors:
error_count = 1
cursor.execute("""
UPDATE job_history
SET status = ?, completed_at = ?, duration_seconds = ?,
posts_scraped = ?, comments_scraped = ?, media_downloaded = ?,
errors = ?, error_count = ?
WHERE job_id = ?
""", (status, completed_at, duration, posts, comments, media, errors, error_count, job_id))
conn.commit()
conn.close()
if status == 'completed':
print(f"✅ Job {job_id} completed: {posts} posts, {comments} comments in {duration:.1f}s")
else:
print(f"❌ Job {job_id} failed: {errors}")
def get_job_history(limit=50, target=None, status=None):
"""Get recent job history."""
conn = get_connection()
cursor = conn.cursor()
sql = "SELECT * FROM job_history WHERE 1=1"
params = []
if target:
sql += " AND target = ?"
params.append(target)
if status:
sql += " AND status = ?"
params.append(status)
sql += " ORDER BY started_at DESC LIMIT ?"
params.append(limit)
cursor.execute(sql, params)
results = [dict(row) for row in cursor.fetchall()]
conn.close()
return results
def get_job_stats():
"""Get aggregated job statistics."""
conn = get_connection()
cursor = conn.cursor()
stats = {}
# Overall counts
cursor.execute("""
SELECT
COUNT(*) as total_jobs,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running,
AVG(duration_seconds) as avg_duration,
SUM(posts_scraped) as total_posts,
SUM(comments_scraped) as total_comments
FROM job_history
""")
row = cursor.fetchone()
if row:
stats.update(dict(row))
# Recent jobs
cursor.execute("""
SELECT target, status, duration_seconds, posts_scraped, started_at
FROM job_history ORDER BY started_at DESC LIMIT 10
""")
stats['recent_jobs'] = [dict(row) for row in cursor.fetchall()]
conn.close()
return stats
def print_job_history(limit=20):
"""Pretty print job history."""
jobs = get_job_history(limit)
print("\n📋 Job History")
print("-" * 80)
print(f"{'ID':<10} {'Target':<15} {'Status':<10} {'Posts':<8} {'Duration':<10} {'Started':<20}")
print("-" * 80)
for job in jobs:
status_icon = "" if job['status'] == 'completed' else "" if job['status'] == 'failed' else "🔄"
duration = f"{job['duration_seconds']:.1f}s" if job['duration_seconds'] else "-"
started = job['started_at'][:19] if job['started_at'] else "-"
dry = " (dry)" if job['dry_run'] else ""
print(f"{status_icon} {job['job_id']:<8} {job['target']:<15} {job['status']:<10} "
f"{job['posts_scraped']:<8} {duration:<10} {started}{dry}")
print("-" * 80)
stats = get_job_stats()
success_rate = (stats['completed'] / stats['total_jobs'] * 100) if stats['total_jobs'] else 0
print(f"\n📊 Stats: {stats['total_jobs']} jobs | {success_rate:.0f}% success | "
f"{stats['total_posts'] or 0} posts total")
# --- SQLITE MAINTENANCE FUNCTIONS ---
def enable_auto_vacuum():
"""Enable incremental auto-vacuum on SQLite database."""
conn = get_connection()
try:
conn.execute("PRAGMA auto_vacuum = INCREMENTAL")
conn.execute("PRAGMA incremental_vacuum")
conn.commit()
print("✅ Auto-vacuum enabled")
finally:
conn.close()
def vacuum_database():
"""Run VACUUM to optimize and compact the database."""
conn = get_connection()
try:
print("🔧 Running VACUUM...")
conn.execute("VACUUM")
print("✅ Database optimized")
finally:
conn.close()
def backup_database(backup_path=None):
"""
Create a backup of the SQLite database.
Args:
backup_path: Optional custom backup path
Returns:
Path to the backup file
"""
import shutil
backup_dir = DATA_DIR / "backups"
backup_dir.mkdir(exist_ok=True)
if backup_path is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = backup_dir / f"reddit_scraper_{timestamp}.db"
shutil.copy2(DB_PATH, backup_path)
# Get file size
size_mb = Path(backup_path).stat().st_size / (1024 * 1024)
print(f"✅ Backup created: {backup_path} ({size_mb:.2f} MB)")
return str(backup_path)
def get_database_info():
"""Get database size and table info."""
info = {}
# File size
if DB_PATH.exists():
info['size_mb'] = DB_PATH.stat().st_size / (1024 * 1024)
conn = get_connection()
cursor = conn.cursor()
# Table counts
tables = ['posts', 'comments', 'job_history', 'alerts', 'subreddits']
info['tables'] = {}
for table in tables:
try:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
info['tables'][table] = cursor.fetchone()[0]
except:
info['tables'][table] = 0
conn.close()
return info
# Initialize on import
init_database()