v3.0: Full suite - Dashboard, Analytics, Scheduling, Notifications, Search

This commit is contained in:
Sanjeev Kumar 2025-12-13 22:17:32 +05:30
parent 2eb9ef9a54
commit 0a1a604f49
16 changed files with 2025 additions and 160 deletions

249
README.md
View file

@ -1,90 +1,148 @@
# 🤖 Universal Reddit Scraper # 🤖 Universal Reddit Scraper Suite
[![Docker Build & Publish](https://github.com/ksanjeev284/reddit-universal-scraper/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/ksanjeev284/reddit-universal-scraper/actions/workflows/docker-publish.yml) [![Docker Build & Publish](https://github.com/ksanjeev284/reddit-universal-scraper/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/ksanjeev284/reddit-universal-scraper/actions/workflows/docker-publish.yml)
A robust, full-featured Reddit scraper that downloads **posts, images, videos, galleries, and comments**. Designed to run on low-resource servers (like AWS Free Tier). A **full-featured** Reddit scraper suite with analytics dashboard, sentiment analysis, scheduled scraping, notifications, and more!
## 🐳 Quick Start (No Installation Needed!)
```bash
docker run -d -v $(pwd)/data:/app/data ghcr.io/ksanjeev284/reddit-universal-scraper:latest delhi --mode full --limit 100
```
## ✨ Features ## ✨ Features
| Feature | Description | | Feature | Description |
|---------|-------------| |---------|-------------|
| 📊 **Full Metadata** | Title, author, score, upvotes, awards, flair, NSFW flags | | 📊 **Full Scraping** | Posts, comments, images, videos, galleries |
| 🖼️ **Image Download** | Automatically downloads all images from posts | | 📈 **Analytics Dashboard** | Beautiful Streamlit web UI |
| 🎬 **Video Download** | Downloads Reddit-hosted videos | | 😀 **Sentiment Analysis** | Analyze post/comment sentiment |
| 🖼️ **Gallery Support** | Extracts and downloads all images from gallery posts | | ☁️ **Keyword Extraction** | Generate word clouds |
| 💬 **Comment Scraping** | Recursively scrapes all comments with threading info | | 🔍 **Search & Filter** | Query scraped data with filters |
| 🔄 **Dual Sources** | Uses old.reddit.com + Redlib mirrors for reliability | | 📅 **Scheduled Scraping** | Cron-style job scheduling |
| 📁 **Organized Output** | Clean folder structure per subreddit | | 📧 **Notifications** | Discord & Telegram alerts |
| 🗄️ **SQLite Database** | Structured data storage |
| 📤 **Multiple Exports** | CSV, JSON, Excel |
## 📁 Output Structure ## 🚀 Quick Start
```
data/
└── r_delhi/
├── posts.csv # All post metadata
├── comments.csv # All comments with threading
└── media/
├── images/ # Downloaded images & galleries
│ ├── abc123_0.jpg
│ ├── abc123_gallery_0.jpg
│ └── ...
└── videos/ # Downloaded videos
└── xyz789_0.mp4
```
## 🚀 Usage
### Full Scrape (Posts + Media + Comments)
```bash ```bash
# Scrape r/delhi with everything # Install dependencies
pip install -r requirements.txt
# Scrape a subreddit (posts + media + comments)
python main.py delhi --mode full --limit 100 python main.py delhi --mode full --limit 100
# Launch analytics dashboard
python main.py --dashboard
```
## 📖 Usage Guide
### 🔄 Scraping Modes
```bash
# Full scrape with everything
python main.py delhi --mode full --limit 100
# History only (no media/comments - faster)
python main.py delhi --mode history --limit 500
# Live monitor (checks every 5 min)
python main.py delhi --mode monitor
# Scrape a user's posts # Scrape a user's posts
python main.py spez --user --mode full --limit 50 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
``` ```
### Posts Only (No Media Download) ### 📊 Analytics Dashboard
```bash
python main.py python --mode full --no-media --limit 200
```
### Posts Only (No Comments)
```bash
python main.py india --mode full --no-comments --limit 100
```
### Live Monitor Mode
```bash
python main.py delhi --mode monitor
```
### Legacy History Mode (Posts Only, No Media)
```bash
python main.py delhi --mode history --limit 500
```
## 🐳 Docker Usage
```bash ```bash
# Build the image # Launch the web dashboard
docker build -t reddit-scraper . python main.py --dashboard
# Full scrape with media # Opens at http://localhost:8501
docker run -d -v $(pwd)/data:/app/data reddit-scraper delhi --mode full --limit 100
# Scrape without media (faster)
docker run -d -v $(pwd)/data:/app/data reddit-scraper delhi --mode full --no-media --limit 500
# Monitor mode (runs continuously)
docker run -d -v $(pwd)/data:/app/data reddit-scraper delhi --mode monitor
``` ```
## 📊 CSV Output Format **Dashboard Features:**
- 📈 Post statistics & charts
- 😀 Sentiment analysis
- ☁️ Keyword extraction
- 🔍 Search & filter interface
- 📤 Export data
### 🔍 Search Data
```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
```
### 😀 Analytics
```bash
# Run sentiment analysis
python main.py --analyze delhi --sentiment
# Extract top keywords
python main.py --analyze delhi --keywords
```
### 📅 Scheduled Scraping
```bash
# Scrape every 60 minutes
python main.py --schedule delhi --every 60
# Scrape with options
python main.py --schedule delhi --every 30 --mode full --limit 50
```
### 📧 Notifications (Discord/Telegram)
**Discord:**
```bash
python main.py delhi --mode monitor --discord-webhook "YOUR_WEBHOOK_URL"
```
**Telegram:**
```bash
python main.py delhi --mode monitor \
--telegram-token "YOUR_BOT_TOKEN" \
--telegram-chat "YOUR_CHAT_ID"
```
## 📁 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/
```
## 📊 Data Output
### posts.csv ### posts.csv
| Column | Description | | Column | Description |
@ -92,50 +150,51 @@ docker run -d -v $(pwd)/data:/app/data reddit-scraper delhi --mode monitor
| id | Reddit post ID | | id | Reddit post ID |
| title | Post title | | title | Post title |
| author | Username | | author | Username |
| created_utc | Timestamp (ISO format) |
| permalink | Reddit URL path |
| url | External/media URL |
| score | Net upvotes | | score | Net upvotes |
| upvote_ratio | Percentage upvoted |
| num_comments | Comment count | | num_comments | Comment count |
| selftext | Post body text | | post_type | text/image/video/gallery |
| post_type | text/image/video/gallery/link | | selftext | Post body |
| flair | Post flair text | | flair | Post flair |
| has_media | Boolean | | is_nsfw | NSFW flag |
| media_downloaded | Boolean | | created_utc | Timestamp |
### comments.csv ### comments.csv
| Column | Description | | Column | Description |
|--------|-------------| |--------|-------------|
| post_permalink | Parent post URL | | comment_id | Comment ID |
| comment_id | Reddit comment ID | | post_permalink | Parent post |
| parent_id | Parent comment/post ID |
| author | Username | | author | Username |
| body | Comment text | | body | Comment text |
| score | Net upvotes | | score | Upvotes |
| created_utc | Timestamp | | depth | Nesting level |
| depth | Nesting level (0 = top-level) |
| is_submitter | Is the post author |
## ⚙️ Command Line Options ## 🐳 Docker
| Option | Description | Default |
|--------|-------------|---------|
| `target` | Subreddit or username | Required |
| `--mode` | `full`, `history`, or `monitor` | `full` |
| `--user` | Target is a user, not subreddit | `false` |
| `--limit` | Max posts to scrape | `100` |
| `--no-media` | Skip downloading images/videos | `false` |
| `--no-comments` | Skip scraping comments | `false` |
## 🛠️ Requirements
```bash ```bash
pip install pandas requests # 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:
```bash
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."
export TELEGRAM_BOT_TOKEN="123456:ABC..."
export TELEGRAM_CHAT_ID="987654321"
``` ```
## 📜 License ## 📜 License
MIT License - Feel free to use, modify, and distribute. MIT License - Feel free to use, modify, and distribute.
## 🤝 Contributing ## 🤝 Contributing
Pull requests are welcome! For major changes, please open an issue first.
Pull requests welcome! For major changes, please open an issue first.

2
alerts/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Alerts module
from .notifications import *

208
alerts/notifications.py Normal file
View file

@ -0,0 +1,208 @@
"""
Notification module - Discord & Telegram alerts
"""
import requests
import json
from datetime import datetime
def send_discord_alert(webhook_url, title, message, posts=None, color=0x5865F2):
"""
Send alert to Discord via webhook.
Args:
webhook_url: Discord webhook URL
title: Alert title
message: Alert message
posts: Optional list of posts to include
color: Embed color (default: Discord blue)
"""
if not webhook_url:
print("⚠️ Discord webhook URL not configured")
return False
embeds = [{
"title": f"🤖 {title}",
"description": message,
"color": color,
"timestamp": datetime.utcnow().isoformat(),
"footer": {"text": "Reddit Scraper Alert"}
}]
# Add post previews
if posts:
fields = []
for post in posts[:5]: # Max 5 posts
fields.append({
"name": post.get('title', 'No Title')[:100],
"value": f"Score: {post.get('score', 0)} | Comments: {post.get('num_comments', 0)}\n[View Post](https://reddit.com{post.get('permalink', '')})",
"inline": False
})
embeds[0]["fields"] = fields
payload = {"embeds": embeds}
try:
response = requests.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=10
)
if response.status_code == 204:
print("✅ Discord alert sent!")
return True
else:
print(f"❌ Discord error: {response.status_code}")
return False
except Exception as e:
print(f"❌ Discord error: {e}")
return False
def send_telegram_alert(bot_token, chat_id, title, message, posts=None):
"""
Send alert to Telegram via bot.
Args:
bot_token: Telegram bot token
chat_id: Chat/Channel ID to send to
title: Alert title
message: Alert message
posts: Optional list of posts to include
"""
if not bot_token or not chat_id:
print("⚠️ Telegram credentials not configured")
return False
# Build message
text = f"🤖 *{title}*\n\n{message}"
if posts:
text += "\n\n📝 *New Posts:*\n"
for post in posts[:5]:
title_text = post.get('title', 'No Title')[:80]
score = post.get('score', 0)
permalink = post.get('permalink', '')
text += f"\n• [{title_text}](https://reddit.com{permalink}) (⬆️ {score})"
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
"disable_web_page_preview": True
}
try:
response = requests.post(url, json=payload, timeout=10)
if response.status_code == 200:
print("✅ Telegram alert sent!")
return True
else:
print(f"❌ Telegram error: {response.json()}")
return False
except Exception as e:
print(f"❌ Telegram error: {e}")
return False
def check_keyword_alerts(posts, keywords, webhook_url=None, telegram_token=None, telegram_chat=None):
"""
Check posts for keyword matches and send alerts.
Args:
posts: List of posts to check
keywords: List of keywords to monitor
webhook_url: Discord webhook URL
telegram_token: Telegram bot token
telegram_chat: Telegram chat ID
Returns:
List of matching posts
"""
if not keywords:
return []
keywords_lower = [k.lower() for k in keywords]
matching_posts = []
for post in posts:
text = f"{post.get('title', '')} {post.get('selftext', '')}".lower()
matched_keywords = []
for keyword in keywords_lower:
if keyword in text:
matched_keywords.append(keyword)
if matched_keywords:
post['matched_keywords'] = matched_keywords
matching_posts.append(post)
if matching_posts:
title = f"Keyword Alert: {len(matching_posts)} matches!"
message = f"Found posts matching: {', '.join(set(k for p in matching_posts for k in p.get('matched_keywords', [])))}"
if webhook_url:
send_discord_alert(webhook_url, title, message, matching_posts, color=0xFF6B6B)
if telegram_token and telegram_chat:
send_telegram_alert(telegram_token, telegram_chat, title, message, matching_posts)
return matching_posts
def send_scrape_summary(subreddit, stats, webhook_url=None, telegram_token=None, telegram_chat=None):
"""
Send a summary after scraping completes.
Args:
subreddit: Subreddit name
stats: Dictionary with scrape statistics
webhook_url: Discord webhook URL
telegram_token: Telegram bot token
telegram_chat: Telegram chat ID
"""
title = f"Scrape Complete: r/{subreddit}"
message = f"""
📊 **Statistics:**
Posts: {stats.get('posts', 0)}
Comments: {stats.get('comments', 0)}
Images: {stats.get('images', 0)}
Videos: {stats.get('videos', 0)}
Duration: {stats.get('duration', 'N/A')}
""".strip()
if webhook_url:
send_discord_alert(webhook_url, title, message, color=0x00D166)
if telegram_token and telegram_chat:
send_telegram_alert(telegram_token, telegram_chat, title, message)
class AlertMonitor:
"""Monitor for keyword-based alerts."""
def __init__(self, keywords, discord_webhook=None, telegram_token=None, telegram_chat=None):
self.keywords = keywords
self.discord_webhook = discord_webhook
self.telegram_token = telegram_token
self.telegram_chat = telegram_chat
self.seen_posts = set()
def check_posts(self, posts):
"""Check new posts for keyword matches."""
new_posts = [p for p in posts if p.get('id') not in self.seen_posts]
if not new_posts:
return []
# Mark as seen
for p in new_posts:
self.seen_posts.add(p.get('id'))
# Check for keywords
matches = check_keyword_alerts(
new_posts,
self.keywords,
self.discord_webhook,
self.telegram_token,
self.telegram_chat
)
return matches

2
analytics/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Analytics module
from .sentiment import *

236
analytics/sentiment.py Normal file
View file

@ -0,0 +1,236 @@
"""
Analytics module - Sentiment Analysis, Word Clouds, Statistics
"""
import re
from collections import Counter
from pathlib import Path
import sys
# Simple sentiment analysis without external dependencies
POSITIVE_WORDS = {
'good', 'great', 'awesome', 'excellent', 'amazing', 'love', 'best', 'perfect',
'nice', 'wonderful', 'fantastic', 'brilliant', 'superb', 'outstanding', 'happy',
'beautiful', 'helpful', 'thanks', 'thank', 'appreciate', 'recommend', 'interesting',
'useful', 'cool', 'fun', 'enjoy', 'like', 'loved', 'impressive', 'incredible'
}
NEGATIVE_WORDS = {
'bad', 'terrible', 'awful', 'horrible', 'hate', 'worst', 'poor', 'disappointing',
'useless', 'waste', 'annoying', 'boring', 'ugly', 'stupid', 'dumb', 'fail',
'wrong', 'broken', 'sad', 'angry', 'frustrated', 'scam', 'fake', 'trash',
'pathetic', 'ridiculous', 'disgusting', 'overpriced', 'avoid', 'never'
}
INTENSIFIERS = {'very', 'really', 'extremely', 'absolutely', 'totally', 'completely'}
def analyze_sentiment(text):
"""
Simple sentiment analysis.
Returns: (score, label)
- score: -1.0 to 1.0
- label: 'positive', 'negative', or 'neutral'
"""
if not text:
return 0.0, 'neutral'
# Clean and tokenize
words = re.findall(r'\b[a-z]+\b', text.lower())
if not words:
return 0.0, 'neutral'
positive_count = 0
negative_count = 0
intensifier_next = False
for word in words:
multiplier = 1.5 if intensifier_next else 1.0
if word in POSITIVE_WORDS:
positive_count += multiplier
elif word in NEGATIVE_WORDS:
negative_count += multiplier
intensifier_next = word in INTENSIFIERS
total = positive_count + negative_count
if total == 0:
return 0.0, 'neutral'
score = (positive_count - negative_count) / len(words)
score = max(-1.0, min(1.0, score * 5)) # Normalize
if score > 0.1:
label = 'positive'
elif score < -0.1:
label = 'negative'
else:
label = 'neutral'
return round(score, 3), label
def analyze_posts_sentiment(posts):
"""Analyze sentiment for a list of posts."""
results = []
sentiment_counts = {'positive': 0, 'negative': 0, 'neutral': 0}
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
sentiment_counts[label] += 1
results.append(post)
return results, sentiment_counts
def analyze_comments_sentiment(comments):
"""Analyze sentiment for comments."""
results = []
sentiment_counts = {'positive': 0, 'negative': 0, 'neutral': 0}
for comment in comments:
score, label = analyze_sentiment(comment.get('body', ''))
comment['sentiment_score'] = score
comment['sentiment_label'] = label
sentiment_counts[label] += 1
results.append(comment)
return results, sentiment_counts
def extract_keywords(texts, top_n=50):
"""Extract most common keywords from texts."""
# Stopwords
stopwords = {
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
'should', 'may', 'might', 'must', 'shall', 'can', 'to', 'of', 'in',
'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through',
'during', 'before', 'after', 'above', 'below', 'between', 'under',
'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where',
'why', 'how', 'all', 'each', 'few', 'more', 'most', 'other', 'some',
'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than',
'too', 'very', 'just', 'and', 'but', 'if', 'or', 'because', 'until',
'while', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'myself',
'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they', 'them', 'what',
'which', 'who', 'whom', 'its', 'his', 'her', 'their', 'our', 'up',
'out', 'about', 'any', 'also', 'get', 'got', 'like', 'one', 'two',
'know', 'even', 'new', 'want', 'way', 'people', 'time', 'year', 'think',
'amp', 'http', 'https', 'www', 'com', 'reddit', 'deleted', 'removed', 'nan'
}
all_words = []
for text in texts:
if text:
words = re.findall(r'\b[a-z]{3,}\b', text.lower())
all_words.extend([w for w in words if w not in stopwords])
return Counter(all_words).most_common(top_n)
def generate_wordcloud_data(texts, top_n=100):
"""Generate word frequency data for word cloud visualization."""
keywords = extract_keywords(texts, top_n)
if not keywords:
return []
max_count = keywords[0][1]
return [
{"text": word, "value": count, "size": int(10 + (count / max_count) * 90)}
for word, count in keywords
]
def calculate_engagement_metrics(posts):
"""Calculate engagement metrics for posts."""
if not posts:
return {}
total_posts = len(posts)
total_score = sum(p.get('score', 0) for p in posts)
total_comments = sum(p.get('num_comments', 0) for p in posts)
total_awards = sum(p.get('total_awards', 0) for p in posts)
# Posts with engagement
engaged_posts = [p for p in posts if p.get('score', 0) > 0 or p.get('num_comments', 0) > 0]
# Top performers
top_by_score = sorted(posts, key=lambda x: x.get('score', 0), reverse=True)[:10]
top_by_comments = sorted(posts, key=lambda x: x.get('num_comments', 0), reverse=True)[:10]
# Post type performance
type_performance = {}
for post in posts:
ptype = post.get('post_type', 'unknown')
if ptype not in type_performance:
type_performance[ptype] = {'count': 0, 'total_score': 0, 'total_comments': 0}
type_performance[ptype]['count'] += 1
type_performance[ptype]['total_score'] += post.get('score', 0)
type_performance[ptype]['total_comments'] += post.get('num_comments', 0)
for ptype in type_performance:
count = type_performance[ptype]['count']
type_performance[ptype]['avg_score'] = type_performance[ptype]['total_score'] / count
type_performance[ptype]['avg_comments'] = type_performance[ptype]['total_comments'] / count
return {
'total_posts': total_posts,
'total_score': total_score,
'total_comments': total_comments,
'total_awards': total_awards,
'avg_score': total_score / total_posts if total_posts else 0,
'avg_comments': total_comments / total_posts if total_posts else 0,
'engagement_rate': len(engaged_posts) / total_posts if total_posts else 0,
'top_by_score': top_by_score,
'top_by_comments': top_by_comments,
'type_performance': type_performance
}
def find_best_posting_times(posts):
"""Analyze best times to post based on engagement."""
hourly_stats = {}
daily_stats = {}
for post in posts:
created = post.get('created_utc', '')
if not created:
continue
try:
# Parse ISO format
from datetime import datetime
dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
hour = dt.hour
day = dt.strftime('%A')
# Hourly
if hour not in hourly_stats:
hourly_stats[hour] = {'count': 0, 'total_score': 0}
hourly_stats[hour]['count'] += 1
hourly_stats[hour]['total_score'] += post.get('score', 0)
# Daily
if day not in daily_stats:
daily_stats[day] = {'count': 0, 'total_score': 0}
daily_stats[day]['count'] += 1
daily_stats[day]['total_score'] += post.get('score', 0)
except:
continue
# Calculate averages
for hour in hourly_stats:
hourly_stats[hour]['avg_score'] = hourly_stats[hour]['total_score'] / hourly_stats[hour]['count']
for day in daily_stats:
daily_stats[day]['avg_score'] = daily_stats[day]['total_score'] / daily_stats[day]['count']
# Find best times
best_hours = sorted(hourly_stats.items(), key=lambda x: x[1]['avg_score'], reverse=True)[:5]
best_days = sorted(daily_stats.items(), key=lambda x: x[1]['avg_score'], reverse=True)[:3]
return {
'hourly_stats': hourly_stats,
'daily_stats': daily_stats,
'best_hours': [(h, s['avg_score']) for h, s in best_hours],
'best_days': [(d, s['avg_score']) for d, s in best_days]
}

58
config.py Normal file
View file

@ -0,0 +1,58 @@
"""
Reddit Scraper Suite - Configuration
"""
import os
from pathlib import Path
# --- PATHS ---
BASE_DIR = Path(__file__).parent
DATA_DIR = BASE_DIR / "data"
DB_PATH = DATA_DIR / "reddit_scraper.db"
# --- SCRAPER SETTINGS ---
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
# Sources: old.reddit.com for residential IPs, mirrors for data centers
MIRRORS = [
"https://old.reddit.com",
"https://redlib.catsarch.com",
"https://redlib.vsls.cz",
"https://r.nf",
"https://libreddit.northboot.xyz",
"https://redlib.tux.pizza"
]
# Rate limiting
REQUEST_TIMEOUT = 15
COOLDOWN_SECONDS = 3
RETRY_WAIT = 30
# Media settings
MAX_IMAGES_PER_POST = 10
MAX_VIDEOS_PER_POST = 2
MAX_GALLERY_IMAGES = 15
# Comment settings
MAX_COMMENT_DEPTH = 5
# --- ASYNC SETTINGS ---
ASYNC_MAX_CONCURRENT = 10
ASYNC_BATCH_SIZE = 50
# --- NOTIFICATION SETTINGS ---
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
# --- DASHBOARD SETTINGS ---
DASHBOARD_HOST = "0.0.0.0"
DASHBOARD_PORT = 8501
# --- SCHEDULER SETTINGS ---
SCHEDULER_TIMEZONE = "Asia/Kolkata"
# --- DATABASE SETTINGS ---
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_PATH}")
# Ensure data directory exists
DATA_DIR.mkdir(exist_ok=True)

1
dashboard/__init__.py Normal file
View file

@ -0,0 +1 @@
# Dashboard module

364
dashboard/app.py Normal file
View file

@ -0,0 +1,364 @@
"""
Reddit Scraper Dashboard - Streamlit Web UI
Run with: streamlit run dashboard/app.py
"""
import streamlit as st
import pandas as pd
from pathlib import Path
import sys
from datetime import datetime
# Add parent to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from analytics.sentiment import (
analyze_posts_sentiment, extract_keywords,
calculate_engagement_metrics, find_best_posting_times
)
from search.query import search_all_data, advanced_search, get_top_posts
# Page config
st.set_page_config(
page_title="Reddit Scraper Dashboard",
page_icon="🤖",
layout="wide",
initial_sidebar_state="expanded"
)
# Custom CSS
st.markdown("""
<style>
.main-header {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(90deg, #FF4500, #FF6B6B);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 1rem;
}
.metric-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
border-radius: 10px;
color: white;
}
.stTabs [data-baseweb="tab-list"] {
gap: 24px;
}
.stTabs [data-baseweb="tab"] {
height: 50px;
padding: 10px 20px;
background-color: #262730;
border-radius: 5px;
}
</style>
""", unsafe_allow_html=True)
def load_subreddit_data(subreddit_path):
"""Load all data for a subreddit."""
data = {}
posts_file = subreddit_path / 'posts.csv'
if posts_file.exists():
data['posts'] = pd.read_csv(posts_file)
comments_file = subreddit_path / 'comments.csv'
if comments_file.exists():
data['comments'] = pd.read_csv(comments_file)
return data
def get_available_subreddits():
"""Get list of scraped subreddits."""
data_dir = Path(__file__).parent.parent / 'data'
subs = []
if data_dir.exists():
for sub_dir in data_dir.iterdir():
if sub_dir.is_dir() and (sub_dir / 'posts.csv').exists():
subs.append(sub_dir.name)
return sorted(subs)
def main():
# Header
st.markdown('<h1 class="main-header">🤖 Reddit Scraper Dashboard</h1>', unsafe_allow_html=True)
# Sidebar
st.sidebar.title("📊 Navigation")
# Get available subreddits
subreddits = get_available_subreddits()
if not subreddits:
st.warning("No scraped data found! Run the scraper first:")
st.code("python main.py <subreddit> --mode full --limit 100")
return
# Subreddit selector
selected_sub = st.sidebar.selectbox(
"Select Subreddit",
subreddits,
format_func=lambda x: f"📁 {x}"
)
# Load data
data_dir = Path(__file__).parent.parent / 'data'
sub_path = data_dir / selected_sub
data = load_subreddit_data(sub_path)
if 'posts' not in data:
st.error("No posts data found!")
return
posts_df = data['posts']
comments_df = data.get('comments', pd.DataFrame())
# Main content tabs
tab1, tab2, tab3, tab4, tab5 = st.tabs([
"📊 Overview", "📈 Analytics", "🔍 Search", "💬 Comments", "⚙️ Scraper"
])
with tab1:
st.header(f"📊 Overview: {selected_sub}")
# Metrics row
col1, col2, col3, col4, col5 = st.columns(5)
with col1:
st.metric("Total Posts", len(posts_df))
with col2:
st.metric("Total Comments", len(comments_df))
with col3:
total_score = posts_df['score'].sum() if 'score' in posts_df else 0
st.metric("Total Score", f"{total_score:,}")
with col4:
avg_score = posts_df['score'].mean() if 'score' in posts_df else 0
st.metric("Avg Score", f"{avg_score:.1f}")
with col5:
media_count = posts_df['has_media'].sum() if 'has_media' in posts_df else 0
st.metric("Media Posts", int(media_count))
st.divider()
# Post type distribution
col1, col2 = st.columns(2)
with col1:
st.subheader("📝 Post Types")
if 'post_type' in posts_df:
type_counts = posts_df['post_type'].value_counts()
st.bar_chart(type_counts)
with col2:
st.subheader("📅 Posts Over Time")
if 'created_utc' in posts_df:
posts_df['date'] = pd.to_datetime(posts_df['created_utc']).dt.date
daily = posts_df.groupby('date').size()
st.line_chart(daily)
st.divider()
# Top posts
st.subheader("🔥 Top Posts by Score")
if 'score' in posts_df:
top_posts = posts_df.nlargest(10, 'score')[['title', 'score', 'num_comments', 'post_type', 'created_utc']]
st.dataframe(top_posts, use_container_width=True)
with tab2:
st.header("📈 Analytics")
# Sentiment Analysis
st.subheader("😀 Sentiment Analysis")
if st.button("Run Sentiment Analysis"):
with st.spinner("Analyzing sentiment..."):
posts_list = posts_df.to_dict('records')
analyzed_posts, sentiment_counts = analyze_posts_sentiment(posts_list)
col1, col2, col3 = st.columns(3)
col1.metric("Positive", sentiment_counts['positive'], delta=None)
col2.metric("Neutral", sentiment_counts['neutral'], delta=None)
col3.metric("Negative", sentiment_counts['negative'], delta=None)
# Pie chart
sentiment_df = pd.DataFrame({
'Sentiment': ['Positive', 'Neutral', 'Negative'],
'Count': [sentiment_counts['positive'], sentiment_counts['neutral'], sentiment_counts['negative']]
})
st.bar_chart(sentiment_df.set_index('Sentiment'))
st.divider()
# Keywords
st.subheader("☁️ Top Keywords")
texts = posts_df['title'].tolist()
if 'selftext' in posts_df:
texts.extend(posts_df['selftext'].dropna().tolist())
keywords = extract_keywords(texts, top_n=30)
if keywords:
kw_df = pd.DataFrame(keywords, columns=['Word', 'Count'])
st.bar_chart(kw_df.set_index('Word').head(20))
st.divider()
# Best posting times
st.subheader("⏰ Best Posting Times")
if 'created_utc' in posts_df:
timing_data = find_best_posting_times(posts_df.to_dict('records'))
if timing_data['best_hours']:
st.write("**Best Hours to Post:**")
for hour, avg_score in timing_data['best_hours']:
st.write(f"{hour}:00 - Avg Score: {avg_score:.1f}")
if timing_data['best_days']:
st.write("**Best Days to Post:**")
for day, avg_score in timing_data['best_days']:
st.write(f"{day} - Avg Score: {avg_score:.1f}")
with tab3:
st.header("🔍 Search Posts")
# Search form
col1, col2 = st.columns([3, 1])
with col1:
search_query = st.text_input("Search query", placeholder="Enter keywords...")
with col2:
min_score = st.number_input("Min Score", min_value=0, value=0)
col3, col4, col5 = st.columns(3)
with col3:
if 'post_type' in posts_df:
post_types = ['All'] + posts_df['post_type'].dropna().unique().tolist()
selected_type = st.selectbox("Post Type", post_types)
with col4:
if 'author' in posts_df:
authors = ['All'] + posts_df['author'].dropna().unique().tolist()[:50]
selected_author = st.selectbox("Author", authors)
with col5:
sort_by = st.selectbox("Sort by", ['score', 'num_comments', 'created_utc'])
# Search button
if st.button("🔍 Search"):
filtered = posts_df.copy()
if search_query:
mask = filtered['title'].str.contains(search_query, case=False, na=False)
if 'selftext' in filtered:
mask |= filtered['selftext'].str.contains(search_query, case=False, na=False)
filtered = filtered[mask]
if min_score > 0:
filtered = filtered[filtered['score'] >= min_score]
if selected_type != 'All' and 'post_type' in filtered:
filtered = filtered[filtered['post_type'] == selected_type]
if selected_author != 'All' and 'author' in filtered:
filtered = filtered[filtered['author'] == selected_author]
filtered = filtered.sort_values(sort_by, ascending=False)
st.write(f"Found {len(filtered)} results")
st.dataframe(filtered[['title', 'score', 'num_comments', 'post_type', 'author', 'created_utc']].head(50), use_container_width=True)
with tab4:
st.header("💬 Comments Analysis")
if len(comments_df) == 0:
st.warning("No comments data found for this subreddit")
else:
col1, col2, col3 = st.columns(3)
with col1:
st.metric("Total Comments", len(comments_df))
with col2:
avg_score = comments_df['score'].mean() if 'score' in comments_df else 0
st.metric("Avg Score", f"{avg_score:.1f}")
with col3:
unique_authors = comments_df['author'].nunique() if 'author' in comments_df else 0
st.metric("Unique Commenters", unique_authors)
st.divider()
# Top comments
st.subheader("🔥 Top Comments by Score")
if 'score' in comments_df:
top_comments = comments_df.nlargest(10, 'score')[['body', 'score', 'author', 'created_utc']]
for _, row in top_comments.iterrows():
with st.expander(f"⬆️ {row['score']} - by u/{row['author']}"):
st.write(row['body'][:500])
st.divider()
# Top commenters
st.subheader("👥 Top Commenters")
if 'author' in comments_df:
top_authors = comments_df['author'].value_counts().head(10)
st.bar_chart(top_authors)
with tab5:
st.header("⚙️ Scraper Controls")
st.subheader("🚀 Start New Scrape")
col1, col2 = st.columns(2)
with col1:
new_sub = st.text_input("Subreddit/User name", placeholder="e.g. python")
is_user = st.checkbox("Is a User (not subreddit)")
with col2:
limit = st.number_input("Post Limit", min_value=10, max_value=5000, value=100)
mode = st.selectbox("Mode", ['full', 'history'])
no_media = st.checkbox("Skip media download")
no_comments = st.checkbox("Skip comments")
if st.button("🚀 Start Scraping"):
st.info(f"Run this command in terminal:")
cmd = f"python main.py {new_sub} --mode {mode} --limit {limit}"
if is_user:
cmd += " --user"
if no_media:
cmd += " --no-media"
if no_comments:
cmd += " --no-comments"
st.code(cmd)
st.divider()
# Export options
st.subheader("📤 Export Data")
export_format = st.selectbox("Format", ['CSV', 'JSON', 'Excel'])
if st.button("📥 Download Posts"):
if export_format == 'CSV':
csv = posts_df.to_csv(index=False)
st.download_button(
"Download CSV",
csv,
f"{selected_sub}_posts.csv",
"text/csv"
)
elif export_format == 'JSON':
json_data = posts_df.to_json(orient='records', indent=2)
st.download_button(
"Download JSON",
json_data,
f"{selected_sub}_posts.json",
"application/json"
)
if __name__ == "__main__":
main()

2
export/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Export module
from .database import *

389
export/database.py Normal file
View file

@ -0,0 +1,389 @@
"""
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
)
""")
# 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
# Initialize on import
init_database()

228
main.py
View file

@ -1,3 +1,7 @@
"""
🤖 Universal Reddit Scraper Suite
Full-featured scraper with analytics, dashboard, notifications, and scheduling.
"""
import requests import requests
import pandas as pd import pandas as pd
import datetime import datetime
@ -8,14 +12,12 @@ import argparse
import random import random
import sys import sys
import json import json
import re
from urllib.parse import urlparse from urllib.parse import urlparse
from concurrent.futures import ThreadPoolExecutor from pathlib import Path
# --- CONFIGURATION --- # --- CONFIGURATION ---
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
# Sources: old.reddit.com for residential IPs, mirrors for data centers
MIRRORS = [ MIRRORS = [
"https://old.reddit.com", "https://old.reddit.com",
"https://redlib.catsarch.com", "https://redlib.catsarch.com",
@ -108,16 +110,13 @@ def get_media_urls(post_data):
"""Extracts all media URLs from a post.""" """Extracts all media URLs from a post."""
media = {"images": [], "videos": [], "galleries": []} media = {"images": [], "videos": [], "galleries": []}
# Direct image link
url = post_data.get('url', '') url = post_data.get('url', '')
if any(ext in url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']): if any(ext in url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
media["images"].append(url) media["images"].append(url)
# Reddit-hosted image
if 'i.redd.it' in url: if 'i.redd.it' in url:
media["images"].append(url) media["images"].append(url)
# Reddit video
if post_data.get('is_video'): if post_data.get('is_video'):
reddit_video = post_data.get('media', {}) reddit_video = post_data.get('media', {})
if reddit_video and 'reddit_video' in reddit_video: if reddit_video and 'reddit_video' in reddit_video:
@ -125,17 +124,14 @@ def get_media_urls(post_data):
if video_url: if video_url:
media["videos"].append(video_url.split('?')[0]) media["videos"].append(video_url.split('?')[0])
# Preview images
preview = post_data.get('preview', {}) preview = post_data.get('preview', {})
if preview and 'images' in preview: if preview and 'images' in preview:
for img in preview['images']: for img in preview['images']:
source = img.get('source', {}) source = img.get('source', {})
if source.get('url'): if source.get('url'):
# Unescape HTML entities
clean_url = source['url'].replace('&amp;', '&') clean_url = source['url'].replace('&amp;', '&')
media["images"].append(clean_url) media["images"].append(clean_url)
# Gallery posts
if post_data.get('is_gallery'): if post_data.get('is_gallery'):
gallery_data = post_data.get('gallery_data', {}) gallery_data = post_data.get('gallery_data', {})
media_metadata = post_data.get('media_metadata', {}) media_metadata = post_data.get('media_metadata', {})
@ -149,7 +145,6 @@ def get_media_urls(post_data):
clean_url = meta['s']['u'].replace('&amp;', '&') clean_url = meta['s']['u'].replace('&amp;', '&')
media["galleries"].append(clean_url) media["galleries"].append(clean_url)
# External video (YouTube, etc.)
if 'youtube.com' in url or 'youtu.be' in url: if 'youtube.com' in url or 'youtu.be' in url:
media["videos"].append(url) media["videos"].append(url)
@ -158,7 +153,6 @@ def get_media_urls(post_data):
def download_media(url, save_path, media_type="image"): def download_media(url, save_path, media_type="image"):
"""Downloads a single media file.""" """Downloads a single media file."""
try: try:
# Skip if already downloaded
if os.path.exists(save_path): if os.path.exists(save_path):
return True return True
@ -169,7 +163,7 @@ def download_media(url, save_path, media_type="image"):
f.write(chunk) f.write(chunk)
return True return True
except Exception as e: except Exception as e:
print(f"⚠️ Failed to download {media_type}: {e}") pass
return False return False
def download_post_media(post_data, dirs, post_id): def download_post_media(post_data, dirs, post_id):
@ -177,23 +171,20 @@ def download_post_media(post_data, dirs, post_id):
media = get_media_urls(post_data) media = get_media_urls(post_data)
downloaded = {"images": 0, "videos": 0} downloaded = {"images": 0, "videos": 0}
# Download images for i, img_url in enumerate(media["images"][:5]):
for i, img_url in enumerate(media["images"][:5]): # Limit to 5 images per post
ext = os.path.splitext(urlparse(img_url).path)[1] or '.jpg' ext = os.path.splitext(urlparse(img_url).path)[1] or '.jpg'
save_path = os.path.join(dirs["images"], f"{post_id}_{i}{ext}") save_path = os.path.join(dirs["images"], f"{post_id}_{i}{ext}")
if download_media(img_url, save_path, "image"): if download_media(img_url, save_path, "image"):
downloaded["images"] += 1 downloaded["images"] += 1
# Download gallery images for i, img_url in enumerate(media["galleries"][:10]):
for i, img_url in enumerate(media["galleries"][:10]): # Limit gallery to 10
ext = '.jpg' ext = '.jpg'
save_path = os.path.join(dirs["images"], f"{post_id}_gallery_{i}{ext}") save_path = os.path.join(dirs["images"], f"{post_id}_gallery_{i}{ext}")
if download_media(img_url, save_path, "gallery"): if download_media(img_url, save_path, "gallery"):
downloaded["images"] += 1 downloaded["images"] += 1
# Download videos for i, vid_url in enumerate(media["videos"][:2]):
for i, vid_url in enumerate(media["videos"][:2]): # Limit to 2 videos if 'youtube' not in vid_url:
if 'youtube' not in vid_url: # Skip YouTube (can't direct download)
ext = '.mp4' ext = '.mp4'
save_path = os.path.join(dirs["videos"], f"{post_id}_{i}{ext}") save_path = os.path.join(dirs["videos"], f"{post_id}_{i}{ext}")
if download_media(vid_url, save_path, "video"): if download_media(vid_url, save_path, "video"):
@ -203,11 +194,10 @@ def download_post_media(post_data, dirs, post_id):
# --- COMMENT SCRAPING --- # --- COMMENT SCRAPING ---
def scrape_comments(permalink, max_depth=3): def scrape_comments(permalink, max_depth=3):
"""Scrapes comments from a post using Reddit JSON endpoint.""" """Scrapes comments from a post."""
comments = [] comments = []
try: try:
# Clean permalink and build URL
if not permalink.startswith('http'): if not permalink.startswith('http'):
url = f"https://old.reddit.com{permalink}.json?limit=100" url = f"https://old.reddit.com{permalink}.json?limit=100"
else: else:
@ -219,13 +209,12 @@ def scrape_comments(permalink, max_depth=3):
data = response.json() data = response.json()
# Comments are in the second element of the response
if len(data) > 1: if len(data) > 1:
comment_data = data[1]['data']['children'] comment_data = data[1]['data']['children']
comments = parse_comments(comment_data, permalink, depth=0, max_depth=max_depth) comments = parse_comments(comment_data, permalink, depth=0, max_depth=max_depth)
except Exception as e: except Exception as e:
print(f"⚠️ Comment fetch error: {e}") pass
return comments return comments
@ -237,7 +226,7 @@ def parse_comments(comment_list, post_permalink, depth=0, max_depth=3):
return comments return comments
for item in comment_list: for item in comment_list:
if item['kind'] != 't1': # Skip non-comment items if item['kind'] != 't1':
continue continue
c = item['data'] c = item['data']
@ -255,7 +244,6 @@ def parse_comments(comment_list, post_permalink, depth=0, max_depth=3):
} }
comments.append(comment) comments.append(comment)
# Parse replies recursively
replies = c.get('replies') replies = c.get('replies')
if replies and isinstance(replies, dict): if replies and isinstance(replies, dict):
reply_children = replies.get('data', {}).get('children', []) reply_children = replies.get('data', {}).get('children', [])
@ -263,12 +251,11 @@ def parse_comments(comment_list, post_permalink, depth=0, max_depth=3):
return comments return comments
# --- ENHANCED POST EXTRACTION --- # --- POST EXTRACTION ---
def extract_post_data(post_json): def extract_post_data(post_json):
"""Extracts comprehensive post data.""" """Extracts comprehensive post data."""
p = post_json p = post_json
# Determine post type
post_type = "text" post_type = "text"
if p.get('is_video'): if p.get('is_video'):
post_type = "video" post_type = "video"
@ -282,39 +269,28 @@ def extract_post_data(post_json):
post_type = "link" post_type = "link"
return { return {
# Basic Info
"id": p.get('id'), "id": p.get('id'),
"title": p.get('title'), "title": p.get('title'),
"author": p.get('author'), "author": p.get('author'),
"created_utc": datetime.datetime.fromtimestamp(p.get('created_utc', 0)).isoformat(), "created_utc": datetime.datetime.fromtimestamp(p.get('created_utc', 0)).isoformat(),
"permalink": p.get('permalink'), "permalink": p.get('permalink'),
"url": p.get('url_overridden_by_dest', p.get('url')), "url": p.get('url_overridden_by_dest', p.get('url')),
# Engagement
"score": p.get('score', 0), "score": p.get('score', 0),
"upvote_ratio": p.get('upvote_ratio', 0), "upvote_ratio": p.get('upvote_ratio', 0),
"num_comments": p.get('num_comments', 0), "num_comments": p.get('num_comments', 0),
"num_crossposts": p.get('num_crossposts', 0), "num_crossposts": p.get('num_crossposts', 0),
# Content
"selftext": p.get('selftext', ''), "selftext": p.get('selftext', ''),
"post_type": post_type, "post_type": post_type,
"is_nsfw": p.get('over_18', False), "is_nsfw": p.get('over_18', False),
"is_spoiler": p.get('spoiler', False), "is_spoiler": p.get('spoiler', False),
# Flair & Awards
"flair": p.get('link_flair_text', ''), "flair": p.get('link_flair_text', ''),
"total_awards": p.get('total_awards_received', 0), "total_awards": p.get('total_awards_received', 0),
# Media flags
"has_media": p.get('is_video', False) or p.get('is_gallery', False) or 'i.redd.it' in p.get('url', ''), "has_media": p.get('is_video', False) or p.get('is_gallery', False) or 'i.redd.it' in p.get('url', ''),
"media_downloaded": False, "media_downloaded": False,
# Source tracking
"source": "History-Full" "source": "History-Full"
} }
# --- MODE 2: FULL HISTORY SCRAPE --- # --- FULL HISTORY SCRAPE ---
def run_full_history(target, limit, is_user=False, download_media_flag=True, scrape_comments_flag=True): def run_full_history(target, limit, is_user=False, download_media_flag=True, scrape_comments_flag=True):
"""Full scrape with images, videos, and comments.""" """Full scrape with images, videos, and comments."""
prefix = "u" if is_user else "r" prefix = "u" if is_user else "r"
@ -331,6 +307,7 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
total_posts = 0 total_posts = 0
total_media = {"images": 0, "videos": 0} total_media = {"images": 0, "videos": 0}
total_comments = 0 total_comments = 0
start_time = time.time()
while total_posts < limit: while total_posts < limit:
random.shuffle(MIRRORS) random.shuffle(MIRRORS)
@ -362,11 +339,9 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
p = child['data'] p = child['data']
post = extract_post_data(p) post = extract_post_data(p)
# Skip if already seen
if post['permalink'] in SEEN_URLS: if post['permalink'] in SEEN_URLS:
continue continue
# Download media
if download_media_flag: if download_media_flag:
downloaded = download_post_media(p, dirs, post['id']) downloaded = download_post_media(p, dirs, post['id'])
post['media_downloaded'] = downloaded['images'] > 0 or downloaded['videos'] > 0 post['media_downloaded'] = downloaded['images'] > 0 or downloaded['videos'] > 0
@ -375,22 +350,19 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
posts.append(post) posts.append(post)
# Scrape comments
if scrape_comments_flag and post['num_comments'] > 0: if scrape_comments_flag and post['num_comments'] > 0:
print(f" 💬 Fetching comments for: {post['title'][:40]}...") print(f" 💬 Fetching comments for: {post['title'][:40]}...")
comments = scrape_comments(post['permalink']) comments = scrape_comments(post['permalink'])
all_comments.extend(comments) all_comments.extend(comments)
total_comments += len(comments) total_comments += len(comments)
time.sleep(1) # Rate limiting for comment fetches time.sleep(1)
# Save data
saved = save_posts_csv(posts, dirs["posts"]) saved = save_posts_csv(posts, dirs["posts"])
total_posts += saved total_posts += saved
if all_comments: if all_comments:
save_comments_csv(all_comments, dirs["comments"]) save_comments_csv(all_comments, dirs["comments"])
# Progress update
print(f"\n📊 Progress: {total_posts}/{limit} posts") print(f"\n📊 Progress: {total_posts}/{limit} posts")
print(f" 🖼️ Images: {total_media['images']} | 🎬 Videos: {total_media['videos']}") print(f" 🖼️ Images: {total_media['images']} | 🎬 Videos: {total_media['videos']}")
print(f" 💬 Comments: {total_comments}") print(f" 💬 Comments: {total_comments}")
@ -398,7 +370,7 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
after = data['data'].get('after') after = data['data'].get('after')
if not after: if not after:
print("\n🏁 Reached end of available history.") print("\n🏁 Reached end of available history.")
return break
success = True success = True
break break
@ -407,6 +379,9 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
print(f" ⚠️ Error with {base_url}: {e}") print(f" ⚠️ Error with {base_url}: {e}")
continue continue
if not after:
break
if not success: if not success:
print("\n❌ All sources failed. Waiting 30s...") print("\n❌ All sources failed. Waiting 30s...")
time.sleep(30) time.sleep(30)
@ -414,6 +389,8 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
print(f"\n⏸️ Cooling down (3s)...") print(f"\n⏸️ Cooling down (3s)...")
time.sleep(3) time.sleep(3)
duration = time.time() - start_time
print("\n" + "=" * 50) print("\n" + "=" * 50)
print("✅ SCRAPE COMPLETE!") print("✅ SCRAPE COMPLETE!")
print(f" 📁 Data saved to: {dirs['base']}") print(f" 📁 Data saved to: {dirs['base']}")
@ -421,8 +398,17 @@ def run_full_history(target, limit, is_user=False, download_media_flag=True, scr
print(f" 🖼️ Total images: {total_media['images']}") print(f" 🖼️ Total images: {total_media['images']}")
print(f" 🎬 Total videos: {total_media['videos']}") print(f" 🎬 Total videos: {total_media['videos']}")
print(f" 💬 Total comments: {total_comments}") print(f" 💬 Total comments: {total_comments}")
print(f" ⏱️ Duration: {duration:.1f}s")
return {
'posts': total_posts,
'images': total_media['images'],
'videos': total_media['videos'],
'comments': total_comments,
'duration': f"{duration:.1f}s"
}
# --- MODE 1: LIVE MONITOR (RSS) - Legacy --- # --- MONITOR MODE ---
def run_monitor(target, is_user=False): def run_monitor(target, is_user=False):
prefix = "u" if is_user else "r" prefix = "u" if is_user else "r"
if is_user: if is_user:
@ -437,7 +423,6 @@ def run_monitor(target, is_user=False):
if response.status_code != 200: if response.status_code != 200:
print(f"❌ RSS blocked (Status {response.status_code}), trying JSON...") print(f"❌ RSS blocked (Status {response.status_code}), trying JSON...")
# Fallback to JSON
run_full_history(target, 25, is_user, download_media_flag=False, scrape_comments_flag=False) run_full_history(target, 25, is_user, download_media_flag=False, scrape_comments_flag=False)
return return
@ -474,33 +459,144 @@ def run_monitor(target, is_user=False):
except Exception as e: except Exception as e:
print(f"❌ Monitor Error: {e}") print(f"❌ Monitor Error: {e}")
# --- CLI ARGS --- # --- CLI ---
if __name__ == "__main__": def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="🤖 Universal Reddit Scraper - Full Media & Comments Support", description="🤖 Universal Reddit Scraper Suite",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Examples: Commands:
python main.py delhi --mode full --limit 100 SCRAPING:
python main.py spez --user --mode full --limit 50 python main.py <target> --mode full --limit 100
python main.py python --mode full --no-media --limit 200 python main.py <target> --mode history --limit 500
python main.py india --mode monitor python main.py <target> --mode monitor
SEARCH:
python main.py --search "keyword" --subreddit delhi
python main.py --search "keyword" --min-score 100
DASHBOARD:
python main.py --dashboard
SCHEDULE:
python main.py --schedule delhi --every 60
ANALYTICS:
python main.py --analyze delhi --sentiment
python main.py --analyze delhi --keywords
""" """
) )
parser.add_argument("target", help="Subreddit name (e.g. 'delhi') or Username (e.g. 'spez')")
parser.add_argument("--mode", choices=["monitor", "history", "full"], default="full", # Scraping args
help="monitor=live RSS, history=posts only, full=posts+media+comments") parser.add_argument("target", nargs='?', help="Subreddit or username to scrape")
parser.add_argument("--user", action="store_true", help="Target is a User, not Subreddit") parser.add_argument("--mode", choices=["monitor", "history", "full"], default="full")
parser.add_argument("--user", action="store_true", help="Target is a user")
parser.add_argument("--limit", type=int, default=100, help="Max posts to scrape") parser.add_argument("--limit", type=int, default=100, help="Max posts to scrape")
parser.add_argument("--no-media", action="store_true", help="Skip downloading images/videos") parser.add_argument("--no-media", action="store_true", help="Skip media download")
parser.add_argument("--no-comments", action="store_true", help="Skip scraping comments") parser.add_argument("--no-comments", action="store_true", help="Skip comments")
# Dashboard
parser.add_argument("--dashboard", action="store_true", help="Launch web dashboard")
# Search
parser.add_argument("--search", type=str, help="Search scraped data")
parser.add_argument("--subreddit", type=str, help="Filter by subreddit")
parser.add_argument("--min-score", type=int, help="Filter by minimum score")
parser.add_argument("--author", type=str, help="Filter by author")
# Analytics
parser.add_argument("--analyze", type=str, help="Run analytics on subreddit")
parser.add_argument("--sentiment", action="store_true", help="Run sentiment analysis")
parser.add_argument("--keywords", action="store_true", help="Extract keywords")
# Schedule
parser.add_argument("--schedule", type=str, help="Schedule scraping for target")
parser.add_argument("--every", type=int, help="Interval in minutes")
# Alerts
parser.add_argument("--alert", type=str, help="Set keyword alert")
parser.add_argument("--discord-webhook", type=str, help="Discord webhook URL")
parser.add_argument("--telegram-token", type=str, help="Telegram bot token")
parser.add_argument("--telegram-chat", type=str, help="Telegram chat ID")
args = parser.parse_args() args = parser.parse_args()
print("=" * 50) print("=" * 50)
print("🤖 UNIVERSAL REDDIT SCRAPER") print("🤖 UNIVERSAL REDDIT SCRAPER SUITE")
print("=" * 50) print("=" * 50)
# Dashboard mode
if args.dashboard:
print("\n🌐 Launching Dashboard...")
print(" Open: http://localhost:8501")
os.system("streamlit run dashboard/app.py")
return
# Search mode
if args.search:
print(f"\n🔍 Searching for: {args.search}")
from search.query import search_all_data, print_search_results
results = search_all_data(
query=args.search,
min_score=args.min_score,
author=args.author
)
print_search_results(results)
return
# Analytics mode
if args.analyze:
print(f"\n📊 Analyzing: {args.analyze}")
# Load data
data_dir = Path(f"data/r_{args.analyze}")
if not data_dir.exists():
print(f"❌ No data found for r/{args.analyze}")
return
posts_file = data_dir / "posts.csv"
if not posts_file.exists():
print(f"❌ No posts data found")
return
import pandas as pd
df = pd.read_csv(posts_file)
posts = df.to_dict('records')
if args.sentiment:
from analytics.sentiment import analyze_posts_sentiment
analyzed, counts = analyze_posts_sentiment(posts)
print(f"\n😀 Sentiment Analysis:")
print(f" Positive: {counts['positive']}")
print(f" Neutral: {counts['neutral']}")
print(f" Negative: {counts['negative']}")
if args.keywords:
from analytics.sentiment import extract_keywords
texts = [str(p.get('title', '') or '') + ' ' + str(p.get('selftext', '') or '') for p in posts]
keywords = extract_keywords(texts, top_n=20)
print(f"\n☁️ Top Keywords:")
for word, count in keywords:
print(f" {word}: {count}")
return
# Schedule mode
if args.schedule:
if not args.every:
print("❌ Please specify --every <minutes>")
return
from scheduler.cron import run_scheduled
run_scheduled(args.schedule, args.every, args.mode, args.limit, args.user)
return
# Regular scraping mode
if not args.target:
parser.print_help()
return
if args.mode == "monitor": if args.mode == "monitor":
prefix = "u" if args.user else "r" prefix = "u" if args.user else "r"
dirs = setup_directories(args.target, prefix) dirs = setup_directories(args.target, prefix)
@ -510,10 +606,12 @@ Examples:
run_monitor(args.target, args.user) run_monitor(args.target, args.user)
time.sleep(300) time.sleep(300)
elif args.mode == "history": elif args.mode == "history":
# Legacy mode - posts only
run_full_history(args.target, args.limit, args.user, 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)
else: # full mode else:
run_full_history(args.target, args.limit, args.user, run_full_history(args.target, args.limit, args.user,
download_media_flag=not args.no_media, download_media_flag=not args.no_media,
scrape_comments_flag=not args.no_comments) scrape_comments_flag=not args.no_comments)
if __name__ == "__main__":
main()

View file

@ -1,2 +1,9 @@
# Core
pandas pandas
requests requests
# Dashboard
streamlit
# Export
openpyxl

2
scheduler/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Scheduler module
from .cron import *

215
scheduler/cron.py Normal file
View file

@ -0,0 +1,215 @@
"""
Scheduler module - Cron-style scheduling for scrape jobs
"""
import time
import threading
from datetime import datetime, timedelta
import json
from pathlib import Path
import sys
class CronScheduler:
"""Simple cron-style scheduler for Reddit scraping jobs."""
def __init__(self):
self.jobs = []
self.running = False
self.thread = None
def add_job(self, target, mode='full', limit=100, is_user=False,
interval_minutes=60, run_at_start=True):
"""
Add a scheduled scraping job.
Args:
target: Subreddit or username
mode: 'full', 'history', or 'monitor'
limit: Post limit per run
is_user: True if target is a user
interval_minutes: Minutes between runs
run_at_start: Run immediately when scheduler starts
"""
job = {
'id': len(self.jobs) + 1,
'target': target,
'mode': mode,
'limit': limit,
'is_user': is_user,
'interval_minutes': interval_minutes,
'run_at_start': run_at_start,
'last_run': None,
'next_run': datetime.now() if run_at_start else datetime.now() + timedelta(minutes=interval_minutes),
'enabled': True,
'run_count': 0
}
self.jobs.append(job)
print(f"📅 Added job #{job['id']}: {'u/' if is_user else 'r/'}{target} every {interval_minutes}min")
return job['id']
def remove_job(self, job_id):
"""Remove a scheduled job."""
self.jobs = [j for j in self.jobs if j['id'] != job_id]
print(f"🗑️ Removed job #{job_id}")
def disable_job(self, job_id):
"""Temporarily disable a job."""
for job in self.jobs:
if job['id'] == job_id:
job['enabled'] = False
print(f"⏸️ Disabled job #{job_id}")
def enable_job(self, job_id):
"""Enable a disabled job."""
for job in self.jobs:
if job['id'] == job_id:
job['enabled'] = True
print(f"▶️ Enabled job #{job_id}")
def list_jobs(self):
"""List all scheduled jobs."""
print("\n📋 Scheduled Jobs:")
print("-" * 60)
for job in self.jobs:
status = "" if job['enabled'] else "⏸️"
prefix = "u/" if job['is_user'] else "r/"
next_run = job['next_run'].strftime("%H:%M:%S") if job['next_run'] else "Never"
print(f"{status} #{job['id']} | {prefix}{job['target']} | "
f"Every {job['interval_minutes']}min | Next: {next_run} | "
f"Runs: {job['run_count']}")
print()
return self.jobs
def _run_job(self, job):
"""Execute a single job."""
# Import here to avoid circular imports
try:
from main import run_full_history
prefix = "u/" if job['is_user'] else "r/"
print(f"\n🚀 Running scheduled job: {prefix}{job['target']}")
run_full_history(
job['target'],
job['limit'],
job['is_user'],
download_media_flag=(job['mode'] == 'full'),
scrape_comments_flag=(job['mode'] == 'full')
)
job['last_run'] = datetime.now()
job['run_count'] += 1
print(f"✅ Job completed: {prefix}{job['target']}")
except Exception as e:
print(f"❌ Job failed: {e}")
def _scheduler_loop(self):
"""Main scheduler loop."""
print("🔄 Scheduler started")
while self.running:
now = datetime.now()
for job in self.jobs:
if not job['enabled']:
continue
if job['next_run'] and now >= job['next_run']:
self._run_job(job)
job['next_run'] = now + timedelta(minutes=job['interval_minutes'])
# Check every 30 seconds
time.sleep(30)
print("🛑 Scheduler stopped")
def start(self):
"""Start the scheduler in background."""
if self.running:
print("⚠️ Scheduler already running")
return
self.running = True
self.thread = threading.Thread(target=self._scheduler_loop, daemon=True)
self.thread.start()
print("✅ Scheduler started in background")
def stop(self):
"""Stop the scheduler."""
self.running = False
if self.thread:
self.thread.join(timeout=5)
print("🛑 Scheduler stopped")
def save_jobs(self, filepath='scheduler_jobs.json'):
"""Save jobs to file."""
jobs_data = []
for job in self.jobs:
job_copy = job.copy()
job_copy['last_run'] = job_copy['last_run'].isoformat() if job_copy['last_run'] else None
job_copy['next_run'] = job_copy['next_run'].isoformat() if job_copy['next_run'] else None
jobs_data.append(job_copy)
with open(filepath, 'w') as f:
json.dump(jobs_data, f, indent=2)
print(f"💾 Saved {len(self.jobs)} jobs to {filepath}")
def load_jobs(self, filepath='scheduler_jobs.json'):
"""Load jobs from file."""
if not Path(filepath).exists():
print("⚠️ No saved jobs found")
return
with open(filepath, 'r') as f:
jobs_data = json.load(f)
for job_data in jobs_data:
if job_data['last_run']:
job_data['last_run'] = datetime.fromisoformat(job_data['last_run'])
if job_data['next_run']:
job_data['next_run'] = datetime.fromisoformat(job_data['next_run'])
self.jobs.append(job_data)
print(f"📂 Loaded {len(jobs_data)} jobs from {filepath}")
# Simple interval-based scheduler for CLI
def run_scheduled(target, interval_minutes, mode='full', limit=100, is_user=False):
"""
Run a scrape job on a schedule.
Args:
target: Subreddit or username
interval_minutes: Minutes between runs
mode: 'full', 'history', or 'monitor'
limit: Post limit per run
is_user: True if target is a user
"""
from main import run_full_history
prefix = "u/" if is_user else "r/"
print(f"📅 Scheduled: {prefix}{target} every {interval_minutes} minutes")
print("Press Ctrl+C to stop\n")
run_count = 0
try:
while True:
run_count += 1
print(f"\n{'='*50}")
print(f"🔄 Run #{run_count} - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*50}")
run_full_history(
target,
limit,
is_user,
download_media_flag=(mode == 'full'),
scrape_comments_flag=(mode == 'full')
)
print(f"\n⏰ Next run in {interval_minutes} minutes...")
time.sleep(interval_minutes * 60)
except KeyboardInterrupt:
print(f"\n\n🛑 Scheduler stopped after {run_count} runs")

2
search/__init__.py Normal file
View file

@ -0,0 +1,2 @@
# Search module
from .query import *

220
search/query.py Normal file
View file

@ -0,0 +1,220 @@
"""
Search & Query module - Search and filter scraped data
"""
import pandas as pd
from pathlib import Path
from datetime import datetime
import re
def search_csv(filepath, query=None, column=None, min_score=None, max_score=None,
start_date=None, end_date=None, post_type=None, author=None, limit=50):
"""
Search within a CSV file with various filters.
Args:
filepath: Path to CSV file
query: Text to search for
column: Specific column to search in (default: all text columns)
min_score: Minimum score filter
max_score: Maximum score filter
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
post_type: Filter by post type (image, video, text, etc.)
author: Filter by author
limit: Maximum results to return
Returns:
DataFrame with matching results
"""
if not Path(filepath).exists():
print(f"❌ File not found: {filepath}")
return pd.DataFrame()
df = pd.read_csv(filepath)
# Text search
if query:
if column and column in df.columns:
mask = df[column].astype(str).str.contains(query, case=False, na=False)
else:
# Search in all text columns
text_cols = ['title', 'selftext', 'body']
mask = pd.Series([False] * len(df))
for col in text_cols:
if col in df.columns:
mask |= df[col].astype(str).str.contains(query, case=False, na=False)
df = df[mask]
# Score filter
if min_score is not None and 'score' in df.columns:
df = df[df['score'] >= min_score]
if max_score is not None and 'score' in df.columns:
df = df[df['score'] <= max_score]
# Date filter
if 'created_utc' in df.columns:
if start_date:
df = df[df['created_utc'] >= start_date]
if end_date:
df = df[df['created_utc'] <= end_date]
# Post type filter
if post_type and 'post_type' in df.columns:
df = df[df['post_type'] == post_type]
# Author filter
if author and 'author' in df.columns:
df = df[df['author'] == author]
return df.head(limit)
def search_all_data(data_dir='data', query=None, **kwargs):
"""
Search across all scraped data.
Args:
data_dir: Data directory path
query: Text to search for
**kwargs: Additional filters passed to search_csv
Returns:
Dictionary with results from each subreddit
"""
results = {}
data_path = Path(data_dir)
if not data_path.exists():
print(f"❌ Data directory not found: {data_dir}")
return results
# Find all posts.csv files
for sub_dir in data_path.iterdir():
if sub_dir.is_dir():
posts_file = sub_dir / 'posts.csv'
if posts_file.exists():
df = search_csv(str(posts_file), query=query, **kwargs)
if len(df) > 0:
results[sub_dir.name] = df
# Also check legacy format
for csv_file in data_path.glob('*.csv'):
if csv_file.stem not in [r.replace('r_', '').replace('u_', '') for r in results.keys()]:
df = search_csv(str(csv_file), query=query, **kwargs)
if len(df) > 0:
results[csv_file.stem] = df
return results
def print_search_results(results, show_preview=True):
"""Pretty print search results."""
total = sum(len(df) for df in results.values())
print(f"\n🔍 Found {total} results across {len(results)} sources\n")
print("=" * 70)
for source, df in results.items():
print(f"\n📁 {source} ({len(df)} matches)")
print("-" * 50)
for _, row in df.iterrows():
title = str(row.get('title', row.get('body', 'N/A')))[:60]
score = row.get('score', 0)
date = str(row.get('created_utc', ''))[:10]
print(f" [{score:>4}⬆] {title}...")
if show_preview and 'selftext' in row and row['selftext']:
preview = str(row['selftext'])[:100].replace('\n', ' ')
print(f" └─ {preview}...")
print()
def advanced_search(data_dir='data', query=None, regex=False, sort_by='score',
ascending=False, **kwargs):
"""
Advanced search with regex support and sorting.
Args:
data_dir: Data directory path
query: Search query (text or regex pattern)
regex: Treat query as regex pattern
sort_by: Column to sort results by
ascending: Sort ascending (default: descending)
**kwargs: Additional filters
Returns:
Combined DataFrame of all results
"""
all_results = []
data_path = Path(data_dir)
for sub_dir in data_path.iterdir():
if sub_dir.is_dir():
posts_file = sub_dir / 'posts.csv'
if posts_file.exists():
df = pd.read_csv(posts_file)
df['source'] = sub_dir.name
all_results.append(df)
if not all_results:
return pd.DataFrame()
combined = pd.concat(all_results, ignore_index=True)
# Apply query
if query:
if regex:
pattern = query
else:
pattern = re.escape(query)
mask = pd.Series([False] * len(combined))
for col in ['title', 'selftext']:
if col in combined.columns:
mask |= combined[col].astype(str).str.contains(pattern, case=False, na=False, regex=True)
combined = combined[mask]
# Apply other filters
if kwargs.get('min_score') and 'score' in combined.columns:
combined = combined[combined['score'] >= kwargs['min_score']]
if kwargs.get('author') and 'author' in combined.columns:
combined = combined[combined['author'] == kwargs['author']]
if kwargs.get('post_type') and 'post_type' in combined.columns:
combined = combined[combined['post_type'] == kwargs['post_type']]
# Sort
if sort_by in combined.columns:
combined = combined.sort_values(sort_by, ascending=ascending)
limit = kwargs.get('limit', 100)
return combined.head(limit)
def get_top_posts(data_dir='data', n=10, by='score'):
"""Get top N posts across all scraped data."""
df = advanced_search(data_dir, sort_by=by, ascending=False, limit=n)
return df
def get_recent_posts(data_dir='data', n=10):
"""Get most recent posts across all scraped data."""
df = advanced_search(data_dir, sort_by='created_utc', ascending=False, limit=n)
return df
def find_author_posts(data_dir='data', author=None):
"""Find all posts by a specific author."""
return advanced_search(data_dir, author=author, limit=1000)
def export_search_results(results, output_path, format='csv'):
"""Export search results to file."""
if isinstance(results, dict):
combined = pd.concat(results.values(), ignore_index=True)
else:
combined = results
if format == 'csv':
combined.to_csv(output_path, index=False)
elif format == 'json':
combined.to_json(output_path, orient='records', indent=2)
elif format == 'excel':
combined.to_excel(output_path, index=False)
print(f"💾 Exported {len(combined)} results to {output_path}")