diff --git a/Dockerfile b/Dockerfile
index 39a0129..7442298 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,45 @@
-FROM python:3.9-slim
+FROM python:3.11-slim
+
+# Set environment variables
ENV PYTHONUNBUFFERED=1
+ENV PYTHONDONTWRITEBYTECODE=1
+
WORKDIR /app
+
+# Install system dependencies (for some Python packages)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy requirements first for better caching
COPY requirements.txt .
-COPY main.py .
RUN pip install --no-cache-dir -r requirements.txt
-RUN mkdir data
+
+# Copy all source code
+COPY main.py .
+COPY config.py .
+COPY analytics/ ./analytics/
+COPY alerts/ ./alerts/
+COPY dashboard/ ./dashboard/
+COPY export/ ./export/
+COPY scheduler/ ./scheduler/
+COPY scraper/ ./scraper/
+COPY search/ ./search/
+COPY plugins/ ./plugins/
+COPY api/ ./api/
+COPY docs/ ./docs/
+
+# Create data directory with subdirectories
+RUN mkdir -p data/backups data/parquet
+
+# Expose ports
+# 8501 = Streamlit Dashboard
+# 8000 = REST API
+EXPOSE 8501 8000
+
+# Health check for API mode
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:8000/health || exit 1
+
+# Default: show help
ENTRYPOINT ["python", "main.py"]
diff --git a/README.md b/README.md
index ed6b126..45f9a51 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://github.com/ksanjeev284/reddit-universal-scraper/actions/workflows/docker-publish.yml)
-A **full-featured** Reddit scraper suite with analytics dashboard, sentiment analysis, scheduled scraping, notifications, and more!
+A **full-featured** Reddit scraper with analytics dashboard, REST API, scheduled scraping, plugins, and more. **No API keys required!**
@@ -11,14 +11,18 @@ A **full-featured** Reddit scraper suite with analytics dashboard, sentiment ana
| Feature | Description |
|---------|-------------|
| π **Full Scraping** | Posts, comments, images, videos, galleries |
-| π **Analytics Dashboard** | Beautiful Streamlit web UI |
+| π **Web Dashboard** | Beautiful Streamlit UI with 7 tabs |
+| π **REST API** | Connect Metabase, Grafana, DuckDB |
+| π **Plugin System** | Extensible post-processing (sentiment, dedupe, keywords) |
+| π **Job Tracking** | Full history with status, duration, errors |
+| π§ͺ **Dry Run Mode** | Test scrape rules without saving data |
+| π¦ **Parquet Export** | Analytics-ready format for DuckDB/warehouses |
| π **Sentiment Analysis** | Analyze post/comment sentiment |
-| βοΈ **Keyword Extraction** | Generate word clouds |
-| π **Search & Filter** | Query scraped data with filters |
| π
**Scheduled Scraping** | Cron-style job scheduling |
| π§ **Notifications** | Discord & Telegram alerts |
-| ποΈ **SQLite Database** | Structured data storage |
-| π€ **Multiple Exports** | CSV, JSON, Excel |
+| ποΈ **SQLite Database** | Structured storage with auto-backup |
+
+---
## π Quick Start
@@ -26,22 +30,25 @@ A **full-featured** Reddit scraper suite with analytics dashboard, sentiment ana
# Install dependencies
pip install -r requirements.txt
-# Scrape a subreddit (posts + media + comments)
-python main.py delhi --mode full --limit 100
+# Scrape a subreddit
+python main.py python --mode full --limit 100
-# Launch analytics dashboard
+# Launch dashboard
python main.py --dashboard
+# Opens at http://localhost:8501
```
-## π Usage Guide
+---
-### π Scraping Modes
+## π All Commands
+
+### π Scraping
```bash
-# Full scrape with everything
+# Full scrape (posts + media + comments)
python main.py delhi --mode full --limit 100
-# History only (no media/comments - faster)
+# Fast history-only (no media/comments)
python main.py delhi --mode history --limit 500
# Live monitor (checks every 5 min)
@@ -51,46 +58,95 @@ python main.py delhi --mode monitor
python main.py spez --user --mode full --limit 50
# Skip media or comments
-python main.py delhi --mode full --no-media --limit 200
-python main.py delhi --mode full --no-comments --limit 200
+python main.py delhi --no-media --limit 200
+python main.py delhi --no-comments --limit 200
```
-### π Analytics Dashboard
+### π§ͺ Dry Run Mode
+
+Test scrape rules without saving any data:
```bash
-# Launch the web dashboard
-python main.py --dashboard
+python main.py python --mode full --limit 50 --dry-run
+```
+Output:
+```
+π§ͺ DRY RUN MODE - No data will be saved
+π§ͺ DRY RUN COMPLETE!
+ π Would scrape: 100 posts
+ π¬ Would scrape: 245 comments
+```
+
+### π Plugins
+
+Enable post-processing plugins:
+
+```bash
+# List available plugins
+python main.py --list-plugins
+
+# Run with plugins enabled
+python main.py python --mode full --plugins
+```
+
+**Built-in Plugins:**
+| Plugin | Description |
+|--------|-------------|
+| `sentiment_tagger` | Adds sentiment scores to posts |
+| `deduplicator` | Removes duplicate posts |
+| `keyword_extractor` | Extracts top keywords |
+
+Create custom plugins in `plugins/` folder.
+
+### π Dashboard
+
+```bash
+python main.py --dashboard
# Opens at http://localhost:8501
```
-**Dashboard Features:**
-- π Post statistics & charts
-- π Sentiment analysis
-- βοΈ Keyword extraction
-- π Search & filter interface
-- π€ Export data
+**Dashboard Tabs:**
+- π Overview - Stats & charts
+- π Analytics - Sentiment & keywords
+- π Search - Query scraped data
+- π¬ Comments - Comment analysis
+- βοΈ Scraper - Start new scrapes
+- π Job History - View all jobs
+- π Integrations - API, export, plugins
-### π Search Data
+### π REST API
```bash
-# Search all scraped data
-python main.py --search "credit card"
-
-# Search with filters
-python main.py --search "laptop" --min-score 100
-python main.py --search "advice" --author username
-python main.py --search "help" --subreddit delhi
+python main.py --api
+# API at http://localhost:8000
+# Docs at http://localhost:8000/docs
```
-### π Analytics
+**Endpoints:**
+| Endpoint | Description |
+|----------|-------------|
+| `GET /posts` | List posts with filters |
+| `GET /comments` | List comments |
+| `GET /subreddits` | All scraped subreddits |
+| `GET /jobs` | Job history |
+| `GET /query?sql=...` | Raw SQL queries |
+| `GET /grafana/query` | Grafana time-series |
+
+### π¦ Export & Maintenance
```bash
-# Run sentiment analysis
-python main.py --analyze delhi --sentiment
+# Export to Parquet (for DuckDB/warehouses)
+python main.py --export-parquet python
-# Extract top keywords
-python main.py --analyze delhi --keywords
+# View job history
+python main.py --job-history
+
+# Backup database
+python main.py --backup
+
+# Optimize database
+python main.py --vacuum
```
### π
Scheduled Scraping
@@ -99,51 +155,125 @@ python main.py --analyze delhi --keywords
# Scrape every 60 minutes
python main.py --schedule delhi --every 60
-# Scrape with options
+# With options
python main.py --schedule delhi --every 30 --mode full --limit 50
```
-### π§ Notifications (Discord/Telegram)
+### π Search & Analytics
-**Discord:**
```bash
-python main.py delhi --mode monitor --discord-webhook "YOUR_WEBHOOK_URL"
+# Search scraped data
+python main.py --search "credit card" --min-score 100
+
+# Run sentiment analysis
+python main.py --analyze delhi --sentiment
+
+# Extract keywords
+python main.py --analyze delhi --keywords
```
-**Telegram:**
+---
+
+## π³ Docker
+
+### Quick Start
+
```bash
-python main.py delhi --mode monitor \
- --telegram-token "YOUR_BOT_TOKEN" \
- --telegram-chat "YOUR_CHAT_ID"
+# Build
+docker build -t reddit-scraper .
+
+# Run scrape
+docker run -v ./data:/app/data reddit-scraper python --limit 100
+
+# Run with plugins
+docker run -v ./data:/app/data reddit-scraper python --plugins
```
+### Docker Compose (Full Stack)
+
+```bash
+# Start API + Dashboard
+docker-compose up -d
+
+# Access:
+# Dashboard: http://localhost:8501
+# API: http://localhost:8000/docs
+```
+
+### Deploy to AWS/VPS
+
+```bash
+# SSH into your server
+ssh user@your-server-ip
+
+# Clone repo
+git clone https://github.com/ksanjeev284/reddit-universal-scraper.git
+cd reddit-universal-scraper
+
+# Start services
+docker-compose up -d
+
+# Open firewall ports
+sudo ufw allow 8000
+sudo ufw allow 8501
+```
+
+Access:
+- `http://your-server-ip:8501` β Dashboard
+- `http://your-server-ip:8000/docs` β API
+
+---
+
+## π External Integrations
+
+### Metabase
+
+1. Start API: `python main.py --api`
+2. Add HTTP datasource: `http://localhost:8000`
+3. Query: `/posts?subreddit=python&limit=100`
+
+### Grafana
+
+1. Install "JSON API" or "Infinity" plugin
+2. Add datasource: `http://localhost:8000`
+3. Use `/grafana/query` for time-series
+
+### DuckDB
+
+```python
+import duckdb
+
+# Export to Parquet first
+# python main.py --export-parquet python
+
+# Query directly
+duckdb.query("SELECT * FROM 'data/parquet/*.parquet'").df()
+```
+
+---
+
## π Project Structure
```
reddit-scraper/
-βββ main.py # Main CLI entry point
-βββ config.py # Configuration settings
-βββ analytics/ # Sentiment & keyword analysis
-β βββ sentiment.py
-βββ alerts/ # Discord & Telegram notifications
-β βββ notifications.py
-βββ dashboard/ # Streamlit web UI
-β βββ app.py
-βββ export/ # Database & export functions
-β βββ database.py
-βββ scheduler/ # Cron-style scheduling
-β βββ cron.py
-βββ search/ # Search & filter engine
-β βββ query.py
-βββ data/ # Scraped data
- βββ r_subreddit/
- βββ posts.csv
- βββ comments.csv
- βββ media/
- βββ images/
- βββ videos/
+βββ main.py # CLI entry point
+βββ config.py # Settings
+βββ analytics/ # Sentiment & keywords
+βββ alerts/ # Discord/Telegram
+βββ api/ # REST API server
+βββ dashboard/ # Streamlit UI
+βββ export/ # Database & exports
+βββ plugins/ # Post-processing plugins
+βββ scheduler/ # Cron scheduling
+βββ search/ # Search engine
+βββ data/
+ βββ r_subreddit/ # Scraped data
+ βββ backups/ # DB backups
+ βββ parquet/ # Parquet exports
```
+---
+
## π Data Output
### posts.csv
@@ -156,9 +286,7 @@ reddit-scraper/
| num_comments | Comment count |
| post_type | text/image/video/gallery |
| selftext | Post body |
-| flair | Post flair |
-| is_nsfw | NSFW flag |
-| created_utc | Timestamp |
+| sentiment_score | -1.0 to 1.0 (with plugins) |
### comments.csv
| Column | Description |
@@ -168,31 +296,20 @@ reddit-scraper/
| author | Username |
| body | Comment text |
| score | Upvotes |
-| depth | Nesting level |
-## π³ Docker
-
-```bash
-# Build
-docker build -t reddit-scraper .
-
-# Full scrape
-docker run -v $(pwd)/data:/app/data reddit-scraper delhi --mode full --limit 100
-
-# Monitor mode
-docker run -d -v $(pwd)/data:/app/data reddit-scraper delhi --mode monitor
-```
-
-## βοΈ Configuration
-
-Edit `config.py` or use environment variables:
+---
+
+## βοΈ Environment Variables
```bash
+# Notifications
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."
export TELEGRAM_BOT_TOKEN="123456:ABC..."
export TELEGRAM_CHAT_ID="987654321"
```
+---
+
## π License
MIT License - Feel free to use, modify, and distribute.
diff --git a/api/__init__.py b/api/__init__.py
new file mode 100644
index 0000000..fe99578
--- /dev/null
+++ b/api/__init__.py
@@ -0,0 +1,2 @@
+"""Reddit Scraper REST API"""
+from .server import app
diff --git a/api/server.py b/api/server.py
new file mode 100644
index 0000000..de70f16
--- /dev/null
+++ b/api/server.py
@@ -0,0 +1,244 @@
+"""
+REST API Module - Expose Reddit Scraper data as a REST API
+For integration with Metabase, Grafana, DreamFactory, and other tools.
+
+Start with: python api/server.py
+Or: uvicorn api.server:app --reload --port 8000
+"""
+from fastapi import FastAPI, Query, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from typing import Optional, List
+import sys
+from pathlib import Path
+
+# Add parent to path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from export.database import (
+ get_connection, search_posts, search_comments,
+ get_subreddit_stats, get_all_subreddits,
+ get_job_history, get_job_stats, get_database_info
+)
+
+# Create FastAPI app
+app = FastAPI(
+ title="Reddit Scraper API",
+ description="REST API for Reddit Scraper data. Use with Metabase, Grafana, or any tool.",
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc"
+)
+
+# Enable CORS for external tools
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], # Allow all origins for local tools
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+# --- HEALTH & INFO ---
+
+@app.get("/", tags=["Info"])
+def root():
+ """API root - basic info."""
+ return {
+ "name": "Reddit Scraper API",
+ "version": "1.0.0",
+ "docs": "/docs",
+ "endpoints": ["/posts", "/comments", "/subreddits", "/jobs", "/stats"]
+ }
+
+
+@app.get("/health", tags=["Info"])
+def health_check():
+ """Health check endpoint."""
+ try:
+ info = get_database_info()
+ return {"status": "healthy", "database": info}
+ except Exception as e:
+ return {"status": "unhealthy", "error": str(e)}
+
+
+@app.get("/info", tags=["Info"])
+def database_info():
+ """Get database info and table counts."""
+ return get_database_info()
+
+
+# --- POSTS ---
+
+@app.get("/posts", tags=["Posts"])
+def list_posts(
+ q: Optional[str] = Query(None, description="Search query"),
+ subreddit: Optional[str] = Query(None, description="Filter by subreddit"),
+ author: Optional[str] = Query(None, description="Filter by author"),
+ min_score: Optional[int] = Query(None, description="Minimum score"),
+ post_type: Optional[str] = Query(None, description="Post type filter"),
+ limit: int = Query(100, ge=1, le=1000, description="Max results")
+):
+ """
+ Get posts with optional filters.
+
+ Use for Grafana dashboards, Metabase queries, or custom integrations.
+ """
+ return search_posts(
+ query=q,
+ subreddit=subreddit,
+ author=author,
+ min_score=min_score,
+ post_type=post_type,
+ limit=limit
+ )
+
+
+@app.get("/posts/{post_id}", tags=["Posts"])
+def get_post(post_id: str):
+ """Get a single post by ID."""
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT * FROM posts WHERE id = ?", (post_id,))
+ row = cursor.fetchone()
+ conn.close()
+
+ if not row:
+ raise HTTPException(status_code=404, detail="Post not found")
+ return dict(row)
+
+
+# --- COMMENTS ---
+
+@app.get("/comments", tags=["Comments"])
+def list_comments(
+ q: Optional[str] = Query(None, description="Search in comment body"),
+ post_id: Optional[str] = Query(None, description="Filter by post ID"),
+ author: Optional[str] = Query(None, description="Filter by author"),
+ min_score: Optional[int] = Query(None, description="Minimum score"),
+ limit: int = Query(100, ge=1, le=1000, description="Max results")
+):
+ """Get comments with optional filters."""
+ return search_comments(
+ query=q,
+ post_id=post_id,
+ author=author,
+ min_score=min_score,
+ limit=limit
+ )
+
+
+# --- SUBREDDITS ---
+
+@app.get("/subreddits", tags=["Subreddits"])
+def list_subreddits():
+ """Get all scraped subreddits with post counts."""
+ return get_all_subreddits()
+
+
+@app.get("/subreddits/{subreddit}/stats", tags=["Subreddits"])
+def subreddit_stats(subreddit: str):
+ """Get detailed statistics for a subreddit."""
+ stats = get_subreddit_stats(subreddit)
+ if not stats.get('total_posts'):
+ raise HTTPException(status_code=404, detail=f"No data for r/{subreddit}")
+ return stats
+
+
+# --- JOBS ---
+
+@app.get("/jobs", tags=["Jobs"])
+def list_jobs(
+ status: Optional[str] = Query(None, description="Filter by status"),
+ target: Optional[str] = Query(None, description="Filter by target"),
+ limit: int = Query(50, ge=1, le=200)
+):
+ """Get job history."""
+ return get_job_history(limit=limit, target=target, status=status)
+
+
+@app.get("/jobs/stats", tags=["Jobs"])
+def job_stats():
+ """Get aggregated job statistics."""
+ return get_job_stats()
+
+
+# --- RAW SQL (for advanced users) ---
+
+@app.get("/query", tags=["Advanced"])
+def raw_query(
+ sql: str = Query(..., description="SQL SELECT query"),
+ limit: int = Query(100, ge=1, le=1000)
+):
+ """
+ Execute a raw SQL SELECT query.
+
+ β οΈ Only SELECT queries allowed. Use for custom Grafana/Metabase queries.
+
+ Example: /query?sql=SELECT title, score FROM posts ORDER BY score DESC
+ """
+ # Security: Only allow SELECT
+ if not sql.strip().upper().startswith("SELECT"):
+ raise HTTPException(status_code=400, detail="Only SELECT queries allowed")
+
+ # Add limit if not present
+ if "LIMIT" not in sql.upper():
+ sql = f"{sql} LIMIT {limit}"
+
+ try:
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute(sql)
+ results = [dict(row) for row in cursor.fetchall()]
+ conn.close()
+ return {"query": sql, "count": len(results), "results": results}
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Query error: {e}")
+
+
+# --- GRAFANA COMPATIBLE ENDPOINTS ---
+
+@app.get("/grafana/search", tags=["Grafana"])
+def grafana_search():
+ """Grafana SimpleJSON datasource - search endpoint."""
+ subs = get_all_subreddits()
+ return [s['subreddit'] for s in subs]
+
+
+@app.post("/grafana/query", tags=["Grafana"])
+def grafana_query(body: dict):
+ """Grafana SimpleJSON datasource - query endpoint."""
+ # Return time series data for Grafana
+ results = []
+
+ for target in body.get('targets', []):
+ subreddit = target.get('target')
+ if subreddit:
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT date(created_utc) as time, COUNT(*) as value
+ FROM posts WHERE subreddit = ?
+ GROUP BY date(created_utc)
+ ORDER BY time
+ """, (subreddit,))
+
+ datapoints = [[row['value'], row['time']] for row in cursor.fetchall()]
+ conn.close()
+
+ results.append({
+ "target": subreddit,
+ "datapoints": datapoints
+ })
+
+ return results
+
+
+# --- CLI ---
+
+if __name__ == "__main__":
+ import uvicorn
+ print("π Starting Reddit Scraper API...")
+ print(" π Docs: http://localhost:8000/docs")
+ print(" π Use with Metabase, Grafana, or any REST client")
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/dashboard/app.py b/dashboard/app.py
index 8e16830..41304fc 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -115,8 +115,8 @@ def main():
comments_df = data.get('comments', pd.DataFrame())
# Main content tabs
- tab1, tab2, tab3, tab4, tab5 = st.tabs([
- "π Overview", "π Analytics", "π Search", "π¬ Comments", "βοΈ Scraper"
+ tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs([
+ "π Overview", "π Analytics", "π Search", "π¬ Comments", "βοΈ Scraper", "π Job History", "π Integrations"
])
with tab1:
@@ -359,6 +359,242 @@ def main():
f"{selected_sub}_posts.json",
"application/json"
)
+
+ with tab6:
+ st.header("π Job History")
+
+ try:
+ from export.database import get_job_history, get_job_stats
+
+ # Job stats
+ stats = get_job_stats()
+
+ col1, col2, col3, col4 = st.columns(4)
+ with col1:
+ st.metric("Total Jobs", stats.get('total_jobs', 0))
+ with col2:
+ st.metric("Completed", stats.get('completed', 0))
+ with col3:
+ st.metric("Failed", stats.get('failed', 0))
+ with col4:
+ avg_dur = stats.get('avg_duration')
+ st.metric("Avg Duration", f"{avg_dur:.1f}s" if avg_dur else "-")
+
+ st.divider()
+
+ # Job history table
+ st.subheader("Recent Jobs")
+
+ col1, col2 = st.columns(2)
+ with col1:
+ filter_status = st.selectbox("Filter by Status", ['All', 'completed', 'failed', 'running'])
+ with col2:
+ limit = st.number_input("Show last N jobs", min_value=10, max_value=100, value=20)
+
+ status_filter = None if filter_status == 'All' else filter_status
+ jobs = get_job_history(limit=limit, status=status_filter)
+
+ if jobs:
+ jobs_df = pd.DataFrame(jobs)
+ # Format for display
+ display_cols = ['job_id', 'target', 'mode', 'status', 'posts_scraped',
+ 'comments_scraped', 'duration_seconds', 'started_at', 'dry_run']
+ display_cols = [c for c in display_cols if c in jobs_df.columns]
+ st.dataframe(jobs_df[display_cols], use_container_width=True)
+
+ # Success rate chart
+ st.subheader("Success Rate")
+ if 'status' in jobs_df.columns:
+ status_counts = jobs_df['status'].value_counts()
+ st.bar_chart(status_counts)
+ else:
+ st.info("No job history found. Run some scrapes first!")
+
+ except Exception as e:
+ st.error(f"Failed to load job history: {e}")
+ st.info("Make sure the database is initialized.")
+
+ with tab7:
+ st.header("π Integrations & Settings")
+
+ # REST API Section
+ st.subheader("π REST API")
+
+ col1, col2 = st.columns(2)
+ with col1:
+ st.markdown("""
+ **Start the API server:**
+ ```bash
+ python main.py --api
+ ```
+ """)
+ with col2:
+ api_port = st.number_input("API Port", value=8000, min_value=1000, max_value=65535)
+ st.code(f"http://localhost:{api_port}/docs")
+
+ st.markdown("""
+ **Available Endpoints:**
+ | Endpoint | Description |
+ |----------|-------------|
+ | `/posts` | List posts with filters |
+ | `/comments` | List comments |
+ | `/subreddits` | All scraped subreddits |
+ | `/jobs` | Job history |
+ | `/query?sql=...` | Raw SQL queries |
+ | `/docs` | Interactive Swagger UI |
+ """)
+
+ st.divider()
+
+ # External Tools
+ st.subheader("π External Tools Integration")
+
+ tool_tabs = st.tabs(["π Metabase", "π Grafana", "π DreamFactory", "𧦠DuckDB"])
+
+ with tool_tabs[0]:
+ st.markdown("""
+ **Metabase Setup:**
+ 1. Start API: `python main.py --api`
+ 2. In Metabase: New Question β Native Query
+ 3. Use HTTP datasource with `http://localhost:8000`
+ 4. Query: `/posts?subreddit=python&limit=100`
+
+ **Or use raw SQL:**
+ ```
+ /query?sql=SELECT title, score FROM posts ORDER BY score DESC
+ ```
+ """)
+
+ with tool_tabs[1]:
+ st.markdown("""
+ **Grafana Setup:**
+ 1. Install "JSON API" or "Infinity" plugin
+ 2. Add datasource: `http://localhost:8000`
+ 3. Use `/grafana/query` for time-series
+
+ **Example Panel Query:**
+ ```sql
+ SELECT date(created_utc) as time, COUNT(*) as posts
+ FROM posts GROUP BY date(created_utc)
+ ```
+ """)
+
+ with tool_tabs[2]:
+ st.markdown("""
+ **DreamFactory Setup:**
+ 1. Point to SQLite file: `data/reddit_scraper.db`
+ 2. Or use REST API: `http://localhost:8000`
+ 3. Auto-generates API for all tables
+ """)
+
+ with tool_tabs[3]:
+ st.markdown("""
+ **DuckDB (Analytics):**
+ 1. Export to Parquet first (see below)
+ 2. Query directly:
+ ```python
+ import duckdb
+ duckdb.query("SELECT * FROM 'data/parquet/*.parquet'").df()
+ ```
+ """)
+
+ st.divider()
+
+ # Parquet Export
+ st.subheader("π¦ Parquet Export")
+
+ col1, col2 = st.columns(2)
+ with col1:
+ export_sub = st.selectbox("Select subreddit to export", subreddits, key="parquet_export")
+ with col2:
+ if st.button("π¦ Export to Parquet"):
+ st.info(f"Run: `python main.py --export-parquet {export_sub.replace('r_', '').replace('u_', '')}`")
+
+ # List existing parquet files
+ from pathlib import Path
+ parquet_dir = Path("data/parquet")
+ if parquet_dir.exists():
+ parquet_files = list(parquet_dir.glob("*.parquet"))
+ if parquet_files:
+ st.write("**Existing Parquet files:**")
+ for f in parquet_files[:10]:
+ size_mb = f.stat().st_size / (1024 * 1024)
+ st.text(f" β’ {f.name} ({size_mb:.2f} MB)")
+
+ st.divider()
+
+ # Database Maintenance
+ st.subheader("π οΈ Database Maintenance")
+
+ col1, col2, col3 = st.columns(3)
+
+ with col1:
+ if st.button("πΎ Backup Database"):
+ st.info("Run: `python main.py --backup`")
+
+ with col2:
+ if st.button("π§Ή Vacuum/Optimize"):
+ st.info("Run: `python main.py --vacuum`")
+
+ with col3:
+ try:
+ from export.database import get_database_info
+ db_info = get_database_info()
+ st.metric("DB Size", f"{db_info.get('size_mb', 0):.2f} MB")
+ except:
+ st.metric("DB Size", "N/A")
+
+ # Show backup files
+ backup_dir = Path("data/backups")
+ if backup_dir.exists():
+ backups = sorted(backup_dir.glob("*.db"), reverse=True)[:5]
+ if backups:
+ st.write("**Recent Backups:**")
+ for b in backups:
+ size_mb = b.stat().st_size / (1024 * 1024)
+ st.text(f" β’ {b.name} ({size_mb:.2f} MB)")
+
+ st.divider()
+
+ # Plugin Configuration
+ st.subheader("π Plugins")
+
+ try:
+ from plugins import load_plugins
+ plugins = load_plugins()
+
+ if plugins:
+ st.write("**Available Plugins:**")
+ for plugin in plugins:
+ status = "β
" if plugin.enabled else "β"
+ st.markdown(f"{status} **{plugin.name}** - {plugin.description}")
+
+ st.info("π‘ Enable plugins when scraping: `python main.py --plugins`")
+ else:
+ st.warning("No plugins found in plugins/ directory")
+ except Exception as e:
+ st.error(f"Plugin loading error: {e}")
+
+ st.divider()
+
+ # Quick Commands Reference
+ st.subheader("π Quick Commands")
+ st.code("""
+# Start REST API
+python main.py --api
+
+# Export to Parquet
+python main.py --export-parquet
+
+# Backup database
+python main.py --backup
+
+# Scrape with plugins
+python main.py --plugins
+
+# Dry run (test without saving)
+python main.py --dry-run
+ """, language="bash")
if __name__ == "__main__":
main()
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..0dfcc93
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,98 @@
+version: '3.8'
+
+# Reddit Scraper Suite - Full Stack
+# Start with: docker-compose up -d
+
+services:
+ # Main Scraper (run scrape jobs)
+ scraper:
+ build: .
+ volumes:
+ - ./data:/app/data # Persist scraped data
+ command: ["--help"] # Override with your scrape command
+ profiles: ["scrape"] # Only run when explicitly requested
+
+ # REST API Server (for Metabase/Grafana integration)
+ api:
+ build: .
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./data:/app/data
+ command: ["--api"]
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ # Streamlit Dashboard
+ dashboard:
+ build: .
+ ports:
+ - "8501:8501"
+ volumes:
+ - ./data:/app/data
+ command: ["--dashboard"]
+ restart: unless-stopped
+ depends_on:
+ - api
+
+ # Scheduled Scraper (optional - uncomment and configure)
+ # scheduler:
+ # build: .
+ # volumes:
+ # - ./data:/app/data
+ # command: ["--schedule", "python", "--every", "60"]
+ # restart: unless-stopped
+
+# Optional: Add Metabase for data visualization
+# Uncomment to enable
+#
+# metabase:
+# image: metabase/metabase:latest
+# ports:
+# - "3000:3000"
+# environment:
+# MB_DB_TYPE: h2
+# volumes:
+# - metabase-data:/metabase-data
+# depends_on:
+# - api
+
+# ===========================================
+# PRODUCTION DEPLOYMENT (AWS/VPS)
+# ===========================================
+# Uncomment the nginx service below for:
+# - HTTPS/SSL termination
+# - Basic authentication
+# - Single port exposure (80/443)
+
+# nginx:
+# image: nginx:alpine
+# ports:
+# - "80:80"
+# - "443:443"
+# volumes:
+# - ./nginx.conf:/etc/nginx/nginx.conf:ro
+# - ./ssl:/etc/nginx/ssl:ro # Add your SSL certs
+# depends_on:
+# - api
+# - dashboard
+
+# volumes:
+# metabase-data:
+
+# ===========================================
+# QUICK DEPLOY TO AWS/VPS:
+# ===========================================
+# 1. SSH into your server
+# 2. git clone
+# 3. docker-compose up -d
+# 4. Open firewall: ports 8000, 8501
+#
+# Access:
+# http://:8501 (Dashboard)
+# http://:8000 (API)
+# ===========================================
diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
new file mode 100644
index 0000000..09fb588
--- /dev/null
+++ b/docs/INTEGRATION.md
@@ -0,0 +1,93 @@
+# External Tools Integration Guide
+
+Connect Metabase, Grafana, DreamFactory, or any REST client to your Reddit scraper data.
+
+---
+
+## Quick Start
+
+```powershell
+# Install dependencies
+pip install fastapi uvicorn
+
+# Start the API server
+python main.py --api
+```
+
+The API will be available at `http://localhost:8000`
+
+---
+
+## API Endpoints
+
+| Endpoint | Description |
+|----------|-------------|
+| `GET /posts` | List posts with filters (q, subreddit, author, min_score) |
+| `GET /posts/{id}` | Get single post |
+| `GET /comments` | List comments with filters |
+| `GET /subreddits` | List all scraped subreddits |
+| `GET /subreddits/{name}/stats` | Get subreddit statistics |
+| `GET /jobs` | View job history |
+| `GET /jobs/stats` | Job statistics |
+| `GET /query?sql=...` | Raw SQL SELECT queries |
+| `GET /docs` | Interactive API documentation |
+
+---
+
+## Metabase Setup
+
+1. Start API: `python main.py --api`
+2. In Metabase, add a new "HTTP" question
+3. Use `http://localhost:8000/posts?limit=1000`
+4. Or use `/query?sql=SELECT * FROM posts` for custom queries
+
+---
+
+## Grafana Setup
+
+1. Install "JSON API" or "Infinity" datasource plugin
+2. Add datasource with URL: `http://localhost:8000`
+3. Use `/grafana/query` for time-series data
+4. Or use `/query?sql=...` for custom queries
+
+Example Grafana query:
+```sql
+SELECT date(created_utc) as time, COUNT(*) as posts
+FROM posts
+GROUP BY date(created_utc)
+```
+
+---
+
+## DreamFactory / REST Clients
+
+The API includes full CORS support. Connect any tool that speaks REST:
+
+```bash
+# Get posts
+curl http://localhost:8000/posts?subreddit=python&limit=10
+
+# Custom SQL query
+curl "http://localhost:8000/query?sql=SELECT title, score FROM posts ORDER BY score DESC LIMIT 5"
+```
+
+---
+
+## Docker Compose (All-in-One)
+
+```yaml
+version: '3'
+services:
+ scraper-api:
+ build: .
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./data:/app/data
+ command: python main.py --api
+
+ metabase:
+ image: metabase/metabase
+ ports:
+ - "3000:3000"
+```
diff --git a/export/database.py b/export/database.py
index 4a1f504..c216716 100644
--- a/export/database.py
+++ b/export/database.py
@@ -112,6 +112,27 @@ def init_database():
)
""")
+ # 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)")
@@ -385,5 +406,237 @@ def get_all_subreddits():
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()
+
diff --git a/export/parquet.py b/export/parquet.py
new file mode 100644
index 0000000..628b1de
--- /dev/null
+++ b/export/parquet.py
@@ -0,0 +1,181 @@
+"""
+Parquet Export Module - For DuckDB/Warehouse integration
+Export scraped data to Parquet format for analytics tools.
+"""
+import pandas as pd
+from pathlib import Path
+from datetime import datetime
+
+def export_to_parquet(subreddit, output_dir=None, prefix="r"):
+ """
+ Export subreddit data to Parquet format.
+
+ Args:
+ subreddit: Subreddit name
+ output_dir: Output directory (default: data/parquet)
+ prefix: "r" for subreddit, "u" for user
+
+ Returns:
+ Dictionary with paths to exported files
+ """
+ try:
+ import pyarrow
+ except ImportError:
+ raise ImportError("pyarrow required for Parquet export. Run: pip install pyarrow")
+
+ # Setup paths
+ data_dir = Path(f"data/{prefix}_{subreddit}")
+ output_path = Path(output_dir) if output_dir else Path("data/parquet")
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ if not data_dir.exists():
+ print(f"β No data found for {prefix}/{subreddit}")
+ return {}
+
+ exported = {}
+ timestamp = datetime.now().strftime("%Y%m%d")
+
+ # Export posts
+ posts_csv = data_dir / "posts.csv"
+ if posts_csv.exists():
+ print(f"π¦ Converting posts to Parquet...")
+ df = pd.read_csv(posts_csv)
+
+ # Convert datetime columns
+ if 'created_utc' in df.columns:
+ df['created_utc'] = pd.to_datetime(df['created_utc'], errors='coerce')
+
+ # Optimize dtypes
+ for col in ['score', 'num_comments', 'num_crossposts', 'total_awards']:
+ if col in df.columns:
+ df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0).astype('int32')
+
+ for col in ['is_nsfw', 'is_spoiler', 'has_media', 'media_downloaded']:
+ if col in df.columns:
+ df[col] = df[col].astype(bool)
+
+ output_file = output_path / f"{subreddit}_posts_{timestamp}.parquet"
+ df.to_parquet(output_file, engine="pyarrow", compression="snappy")
+
+ size_mb = output_file.stat().st_size / (1024 * 1024)
+ print(f" β
{output_file.name} ({len(df)} rows, {size_mb:.2f} MB)")
+ exported['posts'] = str(output_file)
+
+ # Export comments
+ comments_csv = data_dir / "comments.csv"
+ if comments_csv.exists():
+ print(f"π¦ Converting comments to Parquet...")
+ df = pd.read_csv(comments_csv)
+
+ if 'created_utc' in df.columns:
+ df['created_utc'] = pd.to_datetime(df['created_utc'], errors='coerce')
+
+ if 'score' in df.columns:
+ df['score'] = pd.to_numeric(df['score'], errors='coerce').fillna(0).astype('int32')
+
+ output_file = output_path / f"{subreddit}_comments_{timestamp}.parquet"
+ df.to_parquet(output_file, engine="pyarrow", compression="snappy")
+
+ size_mb = output_file.stat().st_size / (1024 * 1024)
+ print(f" β
{output_file.name} ({len(df)} rows, {size_mb:.2f} MB)")
+ exported['comments'] = str(output_file)
+
+ print(f"\nβ
Export complete! Files saved to: {output_path}")
+ print(f" π‘ Query with DuckDB: duckdb.query(\"SELECT * FROM '{exported.get('posts', '')}' LIMIT 10\")")
+
+ return exported
+
+
+def export_database_to_parquet(output_dir=None):
+ """
+ Export entire SQLite database to Parquet files.
+
+ Args:
+ output_dir: Output directory
+
+ Returns:
+ Dictionary with paths to exported files
+ """
+ try:
+ import pyarrow
+ except ImportError:
+ raise ImportError("pyarrow required. Run: pip install pyarrow")
+
+ from export.database import get_connection
+
+ output_path = Path(output_dir) if output_dir else Path("data/parquet")
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ conn = get_connection()
+ exported = {}
+ timestamp = datetime.now().strftime("%Y%m%d")
+
+ tables = ['posts', 'comments', 'job_history']
+
+ for table in tables:
+ try:
+ print(f"π¦ Exporting {table}...")
+ df = pd.read_sql(f"SELECT * FROM {table}", conn)
+
+ if len(df) > 0:
+ output_file = output_path / f"db_{table}_{timestamp}.parquet"
+ df.to_parquet(output_file, engine="pyarrow", compression="snappy")
+
+ size_mb = output_file.stat().st_size / (1024 * 1024)
+ print(f" β
{output_file.name} ({len(df)} rows, {size_mb:.2f} MB)")
+ exported[table] = str(output_file)
+ else:
+ print(f" βοΈ {table} is empty, skipping")
+ except Exception as e:
+ print(f" β Failed to export {table}: {e}")
+
+ conn.close()
+ return exported
+
+
+def list_parquet_files(directory="data/parquet"):
+ """List all Parquet files in directory."""
+ parquet_dir = Path(directory)
+
+ if not parquet_dir.exists():
+ print(f"π No Parquet directory found at {directory}")
+ return []
+
+ files = list(parquet_dir.glob("*.parquet"))
+
+ print(f"\nπ Parquet Files in {directory}:")
+ print("-" * 60)
+
+ for f in files:
+ size_mb = f.stat().st_size / (1024 * 1024)
+ mtime = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
+ print(f" {f.name:<40} {size_mb:>6.2f} MB {mtime}")
+
+ print("-" * 60)
+ print(f"Total: {len(files)} files")
+
+ return [str(f) for f in files]
+
+
+# CLI for testing
+if __name__ == "__main__":
+ import argparse
+
+ parser = argparse.ArgumentParser(description="Parquet Export")
+ parser.add_argument("subreddit", nargs='?', help="Subreddit to export")
+ parser.add_argument("--user", action="store_true", help="Is a user profile")
+ parser.add_argument("--output", type=str, help="Output directory")
+ parser.add_argument("--database", action="store_true", help="Export entire database")
+ parser.add_argument("--list", action="store_true", help="List Parquet files")
+
+ args = parser.parse_args()
+
+ if args.list:
+ list_parquet_files()
+ elif args.database:
+ export_database_to_parquet(args.output)
+ elif args.subreddit:
+ prefix = "u" if args.user else "r"
+ export_to_parquet(args.subreddit, args.output, prefix)
+ else:
+ parser.print_help()
diff --git a/main.py b/main.py
index a02f964..a2eb991 100644
--- a/main.py
+++ b/main.py
@@ -291,15 +291,45 @@ def extract_post_data(post_json):
}
# --- FULL HISTORY SCRAPE ---
-def run_full_history(target, limit, is_user=False, download_media_flag=True, scrape_comments_flag=True):
- """Full scrape with images, videos, and comments."""
+def run_full_history(target, limit, is_user=False, download_media_flag=True,
+ scrape_comments_flag=True, dry_run=False, use_plugins=False):
+ """
+ Full scrape with images, videos, and comments.
+
+ Args:
+ target: Subreddit or username
+ limit: Maximum posts to scrape
+ is_user: True if target is a user
+ download_media_flag: Download images/videos
+ scrape_comments_flag: Scrape comments
+ dry_run: Simulate without saving data
+ use_plugins: Run post-processing plugins
+ """
prefix = "u" if is_user else "r"
- print(f"π Starting FULL HISTORY scrape for {prefix}/{target}")
+ mode = "full" if download_media_flag and scrape_comments_flag else "history"
+
+ # Display mode banner
+ if dry_run:
+ print("=" * 50)
+ print("π§ͺ DRY RUN MODE - No data will be saved")
+ print("=" * 50)
+
+ print(f"π Starting {'DRY RUN' if dry_run else 'FULL HISTORY'} scrape for {prefix}/{target}")
print(f" π Target posts: {limit}")
- print(f" πΌοΈ Download media: {download_media_flag}")
+ print(f" πΌοΈ Download media: {download_media_flag and not dry_run}")
print(f" π¬ Scrape comments: {scrape_comments_flag}")
+ print(f" π Plugins enabled: {use_plugins}")
print("-" * 50)
+ # Start job tracking
+ job_id = None
+ try:
+ from export.database import start_job_record, complete_job_record
+ job_id = start_job_record(target, mode, is_user, dry_run)
+ except Exception as e:
+ print(f"β οΈ Job tracking unavailable: {e}")
+
+ # Setup directories (even for dry run, to check existing data)
dirs = setup_directories(target, prefix)
load_history(dirs["posts"])
@@ -307,97 +337,152 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
total_posts = 0
total_media = {"images": 0, "videos": 0}
total_comments = 0
+ all_scraped_posts = [] # For plugin processing
+ all_scraped_comments = []
start_time = time.time()
+ error_msg = None
- while total_posts < limit:
- random.shuffle(MIRRORS)
- success = False
-
- for base_url in MIRRORS:
- try:
- if is_user:
- path = f"/user/{target}/submitted.json"
- else:
- path = f"/r/{target}/new.json"
-
- target_url = f"{base_url}{path}?limit=100&raw_json=1"
- if after:
- target_url += f"&after={after}"
-
- print(f"\nπ‘ Fetching from: {base_url}")
- response = SESSION.get(target_url, timeout=15)
-
- if response.status_code == 200:
- data = response.json()
- posts = []
- all_comments = []
-
- children = data['data']['children']
- print(f" Found {len(children)} posts in this batch")
-
- for child in children:
- p = child['data']
- post = extract_post_data(p)
-
- if post['permalink'] in SEEN_URLS:
- continue
-
- if download_media_flag:
- downloaded = download_post_media(p, dirs, post['id'])
- post['media_downloaded'] = downloaded['images'] > 0 or downloaded['videos'] > 0
- total_media['images'] += downloaded['images']
- total_media['videos'] += downloaded['videos']
-
- posts.append(post)
-
- if scrape_comments_flag and post['num_comments'] > 0:
- print(f" π¬ Fetching comments for: {post['title'][:40]}...")
- comments = scrape_comments(post['permalink'])
- all_comments.extend(comments)
- total_comments += len(comments)
- time.sleep(1)
-
- saved = save_posts_csv(posts, dirs["posts"])
- total_posts += saved
-
- if all_comments:
- save_comments_csv(all_comments, dirs["comments"])
-
- print(f"\nπ Progress: {total_posts}/{limit} posts")
- print(f" πΌοΈ Images: {total_media['images']} | π¬ Videos: {total_media['videos']}")
- print(f" π¬ Comments: {total_comments}")
-
- after = data['data'].get('after')
- if not after:
- print("\nπ Reached end of available history.")
- break
-
- success = True
- break
-
- except Exception as e:
- print(f" β οΈ Error with {base_url}: {e}")
- continue
-
- if not after:
- break
+ try:
+ while total_posts < limit:
+ random.shuffle(MIRRORS)
+ success = False
- if not success:
- print("\nβ All sources failed. Waiting 30s...")
- time.sleep(30)
- else:
- print(f"\nβΈοΈ Cooling down (3s)...")
- time.sleep(3)
+ for base_url in MIRRORS:
+ try:
+ if is_user:
+ path = f"/user/{target}/submitted.json"
+ else:
+ path = f"/r/{target}/new.json"
+
+ target_url = f"{base_url}{path}?limit=100&raw_json=1"
+ if after:
+ target_url += f"&after={after}"
+
+ print(f"\nπ‘ Fetching from: {base_url}")
+ response = SESSION.get(target_url, timeout=15)
+
+ if response.status_code == 200:
+ data = response.json()
+ posts = []
+ batch_comments = []
+
+ children = data['data']['children']
+ print(f" Found {len(children)} posts in this batch")
+
+ for child in children:
+ p = child['data']
+ post = extract_post_data(p)
+
+ if post['permalink'] in SEEN_URLS:
+ continue
+
+ # Download media (skip in dry run)
+ if download_media_flag and not dry_run:
+ downloaded = download_post_media(p, dirs, post['id'])
+ post['media_downloaded'] = downloaded['images'] > 0 or downloaded['videos'] > 0
+ total_media['images'] += downloaded['images']
+ total_media['videos'] += downloaded['videos']
+
+ posts.append(post)
+
+ # Scrape comments
+ if scrape_comments_flag and post['num_comments'] > 0:
+ print(f" π¬ Fetching comments for: {post['title'][:40]}...")
+ comments = scrape_comments(post['permalink'])
+ batch_comments.extend(comments)
+ total_comments += len(comments)
+ time.sleep(1)
+
+ # Collect for plugins
+ all_scraped_posts.extend(posts)
+ all_scraped_comments.extend(batch_comments)
+
+ # Save data (skip in dry run)
+ if not dry_run:
+ saved = save_posts_csv(posts, dirs["posts"])
+ total_posts += saved
+
+ if batch_comments:
+ save_comments_csv(batch_comments, dirs["comments"])
+ else:
+ # In dry run, just count
+ total_posts += len(posts)
+ print(f" π§ͺ [DRY RUN] Would save {len(posts)} posts")
+
+ print(f"\nπ Progress: {total_posts}/{limit} posts")
+ print(f" πΌοΈ Images: {total_media['images']} | π¬ Videos: {total_media['videos']}")
+ print(f" π¬ Comments: {total_comments}")
+
+ after = data['data'].get('after')
+ if not after:
+ print("\nπ Reached end of available history.")
+ break
+
+ success = True
+ break
+
+ except Exception as e:
+ print(f" β οΈ Error with {base_url}: {e}")
+ continue
+
+ if not after:
+ break
+
+ if not success:
+ print("\nβ All sources failed. Waiting 30s...")
+ time.sleep(30)
+ else:
+ print(f"\nβΈοΈ Cooling down (3s)...")
+ time.sleep(3)
+
+ # Run plugins on collected data
+ if use_plugins and (all_scraped_posts or all_scraped_comments):
+ print("\nπ Running post-processing plugins...")
+ try:
+ from plugins import load_plugins, run_plugins
+ plugins = load_plugins()
+ if plugins:
+ all_scraped_posts, all_scraped_comments = run_plugins(
+ all_scraped_posts, all_scraped_comments, plugins
+ )
+ print(f" β
Processed {len(all_scraped_posts)} posts with {len(plugins)} plugins")
+ else:
+ print(" β οΈ No plugins found")
+ except Exception as e:
+ print(f" β οΈ Plugin error: {e}")
+
+ except Exception as e:
+ error_msg = str(e)
+ print(f"\nβ Scrape error: {e}")
duration = time.time() - start_time
+ # Complete job tracking
+ if job_id:
+ try:
+ status = 'failed' if error_msg else 'completed'
+ complete_job_record(
+ job_id, status,
+ total_posts, total_comments,
+ total_media['images'] + total_media['videos'],
+ error_msg
+ )
+ except Exception as e:
+ print(f"β οΈ Failed to complete job record: {e}")
+
+ # Summary
print("\n" + "=" * 50)
- print("β
SCRAPE COMPLETE!")
- print(f" π Data saved to: {dirs['base']}")
- print(f" π Total posts: {total_posts}")
- print(f" πΌοΈ Total images: {total_media['images']}")
- print(f" π¬ Total videos: {total_media['videos']}")
- print(f" π¬ Total comments: {total_comments}")
+ if dry_run:
+ print("π§ͺ DRY RUN COMPLETE!")
+ print(f" π Would scrape: {total_posts} posts")
+ print(f" π¬ Would scrape: {total_comments} comments")
+ else:
+ print("β
SCRAPE COMPLETE!")
+ print(f" π Data saved to: {dirs['base']}")
+ print(f" π Total posts: {total_posts}")
+ print(f" πΌοΈ Total images: {total_media['images']}")
+ print(f" π¬ Total videos: {total_media['videos']}")
+ print(f" π¬ Total comments: {total_comments}")
print(f" β±οΈ Duration: {duration:.1f}s")
return {
@@ -405,7 +490,9 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
'images': total_media['images'],
'videos': total_media['videos'],
'comments': total_comments,
- 'duration': f"{duration:.1f}s"
+ 'duration': f"{duration:.1f}s",
+ 'dry_run': dry_run,
+ 'job_id': job_id
}
# --- MONITOR MODE ---
@@ -470,6 +557,8 @@ Commands:
python main.py --mode full --limit 100
python main.py --mode history --limit 500
python main.py --mode monitor
+ python main.py --dry-run # Test without saving
+ python main.py --plugins # Enable post-processing
SEARCH:
python main.py --search "keyword" --subreddit delhi
@@ -484,6 +573,16 @@ Commands:
ANALYTICS:
python main.py --analyze delhi --sentiment
python main.py --analyze delhi --keywords
+
+ MAINTENANCE:
+ python main.py --job-history # View job history
+ python main.py --backup # Backup database
+ python main.py --vacuum # Optimize database
+ python main.py --export-parquet python # Export to Parquet
+ python main.py --list-plugins # List available plugins
+
+ REST API:
+ python main.py --api # Start REST API server
"""
)
@@ -519,6 +618,16 @@ Commands:
parser.add_argument("--telegram-token", type=str, help="Telegram bot token")
parser.add_argument("--telegram-chat", type=str, help="Telegram chat ID")
+ # New: Observability & Maintenance
+ parser.add_argument("--dry-run", action="store_true", help="Simulate scrape without saving data")
+ parser.add_argument("--plugins", action="store_true", help="Enable post-processing plugins")
+ parser.add_argument("--list-plugins", action="store_true", help="List available plugins")
+ parser.add_argument("--job-history", action="store_true", help="View job history")
+ parser.add_argument("--backup", action="store_true", help="Backup SQLite database")
+ parser.add_argument("--vacuum", action="store_true", help="Optimize SQLite database")
+ parser.add_argument("--export-parquet", type=str, help="Export subreddit to Parquet format")
+ parser.add_argument("--api", action="store_true", help="Start REST API server (port 8000)")
+
args = parser.parse_args()
print("=" * 50)
@@ -532,6 +641,52 @@ Commands:
os.system("streamlit run dashboard/app.py")
return
+ # REST API mode
+ if args.api:
+ print("\nπ Starting REST API server...")
+ print(" π Docs: http://localhost:8000/docs")
+ print(" π Connect Metabase/Grafana to http://localhost:8000")
+ try:
+ import uvicorn
+ from api.server import app
+ uvicorn.run(app, host="0.0.0.0", port=8000)
+ except ImportError:
+ print("β Install dependencies: pip install fastapi uvicorn")
+ return
+
+ # --- NEW: Maintenance & Observability Commands ---
+
+ # Job history
+ if args.job_history:
+ from export.database import print_job_history
+ print_job_history()
+ return
+
+ # Backup database
+ if args.backup:
+ from export.database import backup_database
+ backup_database()
+ return
+
+ # Vacuum/optimize database
+ if args.vacuum:
+ from export.database import vacuum_database
+ vacuum_database()
+ return
+
+ # Export to Parquet
+ if args.export_parquet:
+ from export.parquet import export_to_parquet
+ prefix = "u" if args.user else "r"
+ export_to_parquet(args.export_parquet, prefix=prefix)
+ return
+
+ # List plugins
+ if args.list_plugins:
+ from plugins import list_plugins
+ list_plugins()
+ return
+
# Search mode
if args.search:
print(f"\nπ Searching for: {args.search}")
@@ -607,11 +762,13 @@ Commands:
time.sleep(300)
elif args.mode == "history":
run_full_history(args.target, args.limit, args.user,
- download_media_flag=False, scrape_comments_flag=False)
+ download_media_flag=False, scrape_comments_flag=False,
+ dry_run=args.dry_run, use_plugins=args.plugins)
else:
run_full_history(args.target, args.limit, args.user,
download_media_flag=not args.no_media,
- scrape_comments_flag=not args.no_comments)
+ scrape_comments_flag=not args.no_comments,
+ dry_run=args.dry_run, use_plugins=args.plugins)
if __name__ == "__main__":
main()
diff --git a/plugins/__init__.py b/plugins/__init__.py
new file mode 100644
index 0000000..3552839
--- /dev/null
+++ b/plugins/__init__.py
@@ -0,0 +1,149 @@
+"""
+Lightweight Plugin System for Post-Processing
+Plugins can process posts and comments after scraping.
+"""
+from abc import ABC, abstractmethod
+from pathlib import Path
+import importlib.util
+import sys
+
+
+class Plugin(ABC):
+ """
+ Base class for scraper plugins.
+
+ To create a plugin:
+ 1. Create a new .py file in the plugins/ directory
+ 2. Create a class that inherits from Plugin
+ 3. Implement the process_posts() method
+ 4. Optionally implement process_comments()
+
+ Example:
+ class MyPlugin(Plugin):
+ name = "my_plugin"
+ description = "Does something cool"
+
+ def process_posts(self, posts):
+ for post in posts:
+ post['processed'] = True
+ return posts
+ """
+ name = "base"
+ description = "Base plugin"
+ enabled = True
+
+ @abstractmethod
+ def process_posts(self, posts: list) -> list:
+ """
+ Process posts after scraping.
+
+ Args:
+ posts: List of post dictionaries
+
+ Returns:
+ Modified list of posts
+ """
+ pass
+
+ def process_comments(self, comments: list) -> list:
+ """
+ Process comments after scraping (optional).
+
+ Args:
+ comments: List of comment dictionaries
+
+ Returns:
+ Modified list of comments
+ """
+ return comments
+
+ def __repr__(self):
+ return f""
+
+
+def load_plugins(plugin_dir=None):
+ """
+ Load all plugins from the plugins directory.
+
+ Args:
+ plugin_dir: Path to plugins directory
+
+ Returns:
+ List of plugin instances
+ """
+ if plugin_dir is None:
+ plugin_dir = Path(__file__).parent
+ else:
+ plugin_dir = Path(plugin_dir)
+
+ plugins = []
+
+ for file in plugin_dir.glob("*.py"):
+ # Skip __init__.py and base files
+ if file.name.startswith("_"):
+ continue
+
+ try:
+ # Load the module
+ spec = importlib.util.spec_from_file_location(file.stem, file)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[file.stem] = module
+ spec.loader.exec_module(module)
+
+ # Find Plugin subclasses
+ for attr_name in dir(module):
+ attr = getattr(module, attr_name)
+ if (isinstance(attr, type) and
+ issubclass(attr, Plugin) and
+ attr != Plugin and
+ hasattr(attr, 'name')):
+
+ plugin_instance = attr()
+ if plugin_instance.enabled:
+ plugins.append(plugin_instance)
+
+ except Exception as e:
+ print(f"β οΈ Failed to load plugin {file.name}: {e}")
+
+ return plugins
+
+
+def run_plugins(posts, comments, plugins):
+ """
+ Run all plugins on scraped data.
+
+ Args:
+ posts: List of posts
+ comments: List of comments
+ plugins: List of plugin instances
+
+ Returns:
+ Tuple of (processed_posts, processed_comments)
+ """
+ for plugin in plugins:
+ try:
+ print(f"π Running plugin: {plugin.name}")
+ posts = plugin.process_posts(posts)
+ comments = plugin.process_comments(comments)
+ except Exception as e:
+ print(f"β οΈ Plugin {plugin.name} failed: {e}")
+
+ return posts, comments
+
+
+def list_plugins(plugin_dir=None):
+ """List all available plugins."""
+ plugins = load_plugins(plugin_dir)
+
+ print("\nπ Available Plugins:")
+ print("-" * 50)
+
+ if not plugins:
+ print(" No plugins found")
+ else:
+ for plugin in plugins:
+ status = "β
" if plugin.enabled else "β"
+ print(f" {status} {plugin.name:<20} {plugin.description}")
+
+ print("-" * 50)
+ return plugins
diff --git a/plugins/deduplicator.py b/plugins/deduplicator.py
new file mode 100644
index 0000000..9cfa4b4
--- /dev/null
+++ b/plugins/deduplicator.py
@@ -0,0 +1,45 @@
+"""
+Deduplicator Plugin
+Removes duplicate posts based on permalink.
+"""
+from plugins import Plugin
+
+
+class Deduplicator(Plugin):
+ """Remove duplicate posts by permalink."""
+
+ name = "deduplicator"
+ description = "Removes duplicate posts by permalink"
+ enabled = True
+
+ def process_posts(self, posts):
+ """Remove duplicate posts."""
+ seen = set()
+ unique = []
+ duplicates = 0
+
+ for post in posts:
+ key = post.get('permalink')
+ if key and key not in seen:
+ seen.add(key)
+ unique.append(post)
+ else:
+ duplicates += 1
+
+ if duplicates > 0:
+ print(f" π Removed {duplicates} duplicate posts")
+
+ return unique
+
+ def process_comments(self, comments):
+ """Remove duplicate comments."""
+ seen = set()
+ unique = []
+
+ for comment in comments:
+ key = comment.get('comment_id')
+ if key and key not in seen:
+ seen.add(key)
+ unique.append(comment)
+
+ return unique
diff --git a/plugins/keyword_extractor.py b/plugins/keyword_extractor.py
new file mode 100644
index 0000000..5288b47
--- /dev/null
+++ b/plugins/keyword_extractor.py
@@ -0,0 +1,35 @@
+"""
+Keyword Extractor Plugin
+Extracts and tags posts with top keywords.
+"""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from plugins import Plugin
+from analytics.sentiment import extract_keywords
+
+
+class KeywordExtractor(Plugin):
+ """Extract and add keywords to posts."""
+
+ name = "keyword_extractor"
+ description = "Adds top keywords to each post"
+ enabled = True
+ top_n = 5 # Number of keywords per post
+
+ def process_posts(self, posts):
+ """Add keywords to each post."""
+ for post in posts:
+ text = f"{post.get('title', '')} {post.get('selftext', '')}"
+ keywords = extract_keywords([text], top_n=self.top_n)
+ post['keywords'] = ','.join([kw for kw, count in keywords])
+
+ # Also extract global keywords
+ all_texts = [f"{p.get('title', '')} {p.get('selftext', '')}" for p in posts]
+ global_keywords = extract_keywords(all_texts, top_n=10)
+
+ print(f" π·οΈ Top keywords: {', '.join([kw for kw, _ in global_keywords[:5]])}")
+
+ return posts
diff --git a/plugins/sentiment_tagger.py b/plugins/sentiment_tagger.py
new file mode 100644
index 0000000..061bc99
--- /dev/null
+++ b/plugins/sentiment_tagger.py
@@ -0,0 +1,45 @@
+"""
+Sentiment Tagger Plugin
+Adds sentiment scores and labels to posts and comments.
+"""
+import sys
+from pathlib import Path
+
+# Add parent to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from plugins import Plugin
+from analytics.sentiment import analyze_sentiment
+
+
+class SentimentTagger(Plugin):
+ """Add sentiment analysis to scraped content."""
+
+ name = "sentiment_tagger"
+ description = "Adds sentiment scores and labels to posts"
+ enabled = True
+
+ def process_posts(self, posts):
+ """Add sentiment to posts."""
+ for post in posts:
+ text = f"{post.get('title', '')} {post.get('selftext', '')}"
+ score, label = analyze_sentiment(text)
+ post['sentiment_score'] = score
+ post['sentiment_label'] = label
+
+ # Count sentiments
+ pos = sum(1 for p in posts if p.get('sentiment_label') == 'positive')
+ neg = sum(1 for p in posts if p.get('sentiment_label') == 'negative')
+ neu = len(posts) - pos - neg
+
+ print(f" π Sentiment: {pos} positive, {neu} neutral, {neg} negative")
+ return posts
+
+ def process_comments(self, comments):
+ """Add sentiment to comments."""
+ for comment in comments:
+ score, label = analyze_sentiment(comment.get('body', ''))
+ comment['sentiment_score'] = score
+ comment['sentiment_label'] = label
+
+ return comments
diff --git a/requirements.txt b/requirements.txt
index b3ed494..99a4b61 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,3 +11,8 @@ streamlit
# Export
openpyxl
+pyarrow
+
+# REST API
+fastapi
+uvicorn