feat: Async scraper, cloud upload, subreddit stats
This commit is contained in:
parent
0a1a604f49
commit
a623e5c12d
5 changed files with 943 additions and 0 deletions
243
analytics/subreddit_stats.py
Normal file
243
analytics/subreddit_stats.py
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
"""
|
||||||
|
Subreddit Statistics - Subscribers, rules, mods, and metadata
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
def get_subreddit_about(subreddit):
|
||||||
|
"""
|
||||||
|
Fetch subreddit metadata (subscribers, description, rules, etc.)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name (without r/)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with subreddit info
|
||||||
|
"""
|
||||||
|
url = f"https://old.reddit.com/r/{subreddit}/about.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"❌ Failed to fetch r/{subreddit} info: {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()['data']
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": data.get('display_name'),
|
||||||
|
"title": data.get('title'),
|
||||||
|
"description": data.get('public_description'),
|
||||||
|
"subscribers": data.get('subscribers', 0),
|
||||||
|
"active_users": data.get('accounts_active', 0),
|
||||||
|
"created_utc": datetime.fromtimestamp(data.get('created_utc', 0)).isoformat(),
|
||||||
|
"over_18": data.get('over18', False),
|
||||||
|
"subreddit_type": data.get('subreddit_type'), # public, private, restricted
|
||||||
|
"lang": data.get('lang'),
|
||||||
|
"icon_url": data.get('icon_img', '').split('?')[0] if data.get('icon_img') else None,
|
||||||
|
"banner_url": data.get('banner_img', '').split('?')[0] if data.get('banner_img') else None,
|
||||||
|
"header_url": data.get('header_img'),
|
||||||
|
"community_icon": data.get('community_icon', '').split('?')[0] if data.get('community_icon') else None,
|
||||||
|
"wiki_enabled": data.get('wiki_enabled', False),
|
||||||
|
"spoilers_enabled": data.get('spoilers_enabled', False),
|
||||||
|
"allow_videos": data.get('allow_videos', False),
|
||||||
|
"allow_images": data.get('allow_images', False),
|
||||||
|
"allow_polls": data.get('allow_polls', False),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error fetching subreddit info: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_subreddit_rules(subreddit):
|
||||||
|
"""
|
||||||
|
Fetch subreddit rules.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rule dictionaries
|
||||||
|
"""
|
||||||
|
url = f"https://old.reddit.com/r/{subreddit}/about/rules.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
rules = []
|
||||||
|
|
||||||
|
for rule in data.get('rules', []):
|
||||||
|
rules.append({
|
||||||
|
"short_name": rule.get('short_name'),
|
||||||
|
"description": rule.get('description'),
|
||||||
|
"priority": rule.get('priority'),
|
||||||
|
"kind": rule.get('kind'), # link, comment, all
|
||||||
|
"created_utc": datetime.fromtimestamp(rule.get('created_utc', 0)).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
return rules
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error fetching rules: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_subreddit_mods(subreddit):
|
||||||
|
"""
|
||||||
|
Fetch subreddit moderators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of moderator usernames
|
||||||
|
"""
|
||||||
|
url = f"https://old.reddit.com/r/{subreddit}/about/moderators.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
mods = []
|
||||||
|
|
||||||
|
for mod in data.get('data', {}).get('children', []):
|
||||||
|
mods.append({
|
||||||
|
"name": mod.get('name'),
|
||||||
|
"permissions": mod.get('mod_permissions', []),
|
||||||
|
"added_utc": datetime.fromtimestamp(mod.get('date', 0)).isoformat() if mod.get('date') else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return mods
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error fetching mods: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_subreddit_flairs(subreddit):
|
||||||
|
"""
|
||||||
|
Fetch available post flairs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of flair options
|
||||||
|
"""
|
||||||
|
url = f"https://old.reddit.com/r/{subreddit}/api/link_flair_v2.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return []
|
||||||
|
|
||||||
|
flairs = []
|
||||||
|
for flair in response.json():
|
||||||
|
flairs.append({
|
||||||
|
"text": flair.get('text'),
|
||||||
|
"id": flair.get('id'),
|
||||||
|
"background_color": flair.get('background_color'),
|
||||||
|
"text_color": flair.get('text_color'),
|
||||||
|
"type": flair.get('type')
|
||||||
|
})
|
||||||
|
|
||||||
|
return flairs
|
||||||
|
except Exception as e:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_full_subreddit_stats(subreddit):
|
||||||
|
"""
|
||||||
|
Get comprehensive subreddit statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with all stats
|
||||||
|
"""
|
||||||
|
print(f"📊 Fetching stats for r/{subreddit}...")
|
||||||
|
|
||||||
|
about = get_subreddit_about(subreddit)
|
||||||
|
|
||||||
|
if not about:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rules = get_subreddit_rules(subreddit)
|
||||||
|
mods = get_subreddit_mods(subreddit)
|
||||||
|
flairs = get_subreddit_flairs(subreddit)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
**about,
|
||||||
|
"rules": rules,
|
||||||
|
"rules_count": len(rules),
|
||||||
|
"moderators": mods,
|
||||||
|
"moderator_count": len(mods),
|
||||||
|
"flairs": flairs,
|
||||||
|
"flair_count": len(flairs),
|
||||||
|
"fetched_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print(f"\n📊 r/{subreddit} Statistics:")
|
||||||
|
print(f" 👥 Subscribers: {stats['subscribers']:,}")
|
||||||
|
print(f" 🟢 Active Users: {stats['active_users']:,}")
|
||||||
|
print(f" 📜 Rules: {stats['rules_count']}")
|
||||||
|
print(f" 👮 Moderators: {stats['moderator_count']}")
|
||||||
|
print(f" 🏷️ Flairs: {stats['flair_count']}")
|
||||||
|
print(f" 📅 Created: {stats['created_utc'][:10]}")
|
||||||
|
print(f" 🔞 NSFW: {stats['over_18']}")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def save_subreddit_stats(subreddit, output_dir="data"):
|
||||||
|
"""
|
||||||
|
Fetch and save subreddit stats to JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name
|
||||||
|
output_dir: Output directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to saved file
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
stats = get_full_subreddit_stats(subreddit)
|
||||||
|
|
||||||
|
if not stats:
|
||||||
|
return None
|
||||||
|
|
||||||
|
save_dir = f"{output_dir}/r_{subreddit}"
|
||||||
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
|
||||||
|
filepath = f"{save_dir}/subreddit_stats.json"
|
||||||
|
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(stats, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f"\n💾 Saved to {filepath}")
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
# CLI for testing
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Subreddit Statistics")
|
||||||
|
parser.add_argument("subreddit", help="Subreddit name")
|
||||||
|
parser.add_argument("--save", action="store_true", help="Save to JSON")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.save:
|
||||||
|
save_subreddit_stats(args.subreddit)
|
||||||
|
else:
|
||||||
|
stats = get_full_subreddit_stats(args.subreddit)
|
||||||
|
if stats:
|
||||||
|
print(f"\n📝 Description: {stats['description'][:200]}..." if stats['description'] else "")
|
||||||
313
export/cloud.py
Normal file
313
export/cloud.py
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
"""
|
||||||
|
Cloud Upload Module - S3 and Google Drive integration
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Try importing boto3 for S3
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
HAS_BOTO3 = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_BOTO3 = False
|
||||||
|
|
||||||
|
# Try importing Google Drive API
|
||||||
|
try:
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from googleapiclient.http import MediaFileUpload
|
||||||
|
HAS_GDRIVE = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_GDRIVE = False
|
||||||
|
|
||||||
|
|
||||||
|
class S3Uploader:
|
||||||
|
"""Upload scraped data to AWS S3."""
|
||||||
|
|
||||||
|
def __init__(self, bucket_name, aws_access_key=None, aws_secret_key=None, region='us-east-1'):
|
||||||
|
"""
|
||||||
|
Initialize S3 uploader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bucket_name: S3 bucket name
|
||||||
|
aws_access_key: Optional, uses env/config if not provided
|
||||||
|
aws_secret_key: Optional, uses env/config if not provided
|
||||||
|
region: AWS region
|
||||||
|
"""
|
||||||
|
if not HAS_BOTO3:
|
||||||
|
raise ImportError("boto3 not installed. Run: pip install boto3")
|
||||||
|
|
||||||
|
self.bucket_name = bucket_name
|
||||||
|
self.region = region
|
||||||
|
|
||||||
|
# Use provided credentials or fall back to env vars
|
||||||
|
self.s3 = boto3.client(
|
||||||
|
's3',
|
||||||
|
aws_access_key_id=aws_access_key or os.getenv('AWS_ACCESS_KEY_ID'),
|
||||||
|
aws_secret_access_key=aws_secret_key or os.getenv('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
region_name=region
|
||||||
|
)
|
||||||
|
|
||||||
|
def upload_file(self, local_path, s3_key=None):
|
||||||
|
"""
|
||||||
|
Upload a single file to S3.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_path: Local file path
|
||||||
|
s3_key: S3 object key (default: same as filename)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
S3 URL or None on failure
|
||||||
|
"""
|
||||||
|
local_path = Path(local_path)
|
||||||
|
|
||||||
|
if not local_path.exists():
|
||||||
|
print(f"❌ File not found: {local_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
s3_key = s3_key or local_path.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.s3.upload_file(str(local_path), self.bucket_name, s3_key)
|
||||||
|
url = f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{s3_key}"
|
||||||
|
print(f"✅ Uploaded: {s3_key}")
|
||||||
|
return url
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"❌ S3 upload failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def upload_directory(self, local_dir, s3_prefix=""):
|
||||||
|
"""
|
||||||
|
Upload entire directory to S3.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_dir: Local directory path
|
||||||
|
s3_prefix: Prefix for S3 keys
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of uploaded files
|
||||||
|
"""
|
||||||
|
local_dir = Path(local_dir)
|
||||||
|
|
||||||
|
if not local_dir.exists():
|
||||||
|
print(f"❌ Directory not found: {local_dir}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
uploaded = {}
|
||||||
|
|
||||||
|
for file_path in local_dir.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
relative_path = file_path.relative_to(local_dir)
|
||||||
|
s3_key = f"{s3_prefix}/{relative_path}" if s3_prefix else str(relative_path)
|
||||||
|
s3_key = s3_key.replace('\\', '/') # Windows path fix
|
||||||
|
|
||||||
|
url = self.upload_file(file_path, s3_key)
|
||||||
|
if url:
|
||||||
|
uploaded[str(relative_path)] = url
|
||||||
|
|
||||||
|
print(f"\n📤 Uploaded {len(uploaded)} files to S3")
|
||||||
|
return uploaded
|
||||||
|
|
||||||
|
def upload_subreddit_data(self, subreddit, prefix="u"):
|
||||||
|
"""
|
||||||
|
Upload all data for a subreddit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name
|
||||||
|
prefix: "r" for subreddit, "u" for user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Upload results
|
||||||
|
"""
|
||||||
|
data_dir = Path(f"data/{prefix}_{subreddit}")
|
||||||
|
|
||||||
|
if not data_dir.exists():
|
||||||
|
print(f"❌ Data not found for {prefix}/{subreddit}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
s3_prefix = f"reddit/{prefix}_{subreddit}/{timestamp}"
|
||||||
|
|
||||||
|
return self.upload_directory(data_dir, s3_prefix)
|
||||||
|
|
||||||
|
def list_uploads(self, prefix="reddit/"):
|
||||||
|
"""List all uploaded data in S3."""
|
||||||
|
try:
|
||||||
|
response = self.s3.list_objects_v2(
|
||||||
|
Bucket=self.bucket_name,
|
||||||
|
Prefix=prefix
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = response.get('Contents', [])
|
||||||
|
|
||||||
|
print(f"\n📁 S3 Contents ({self.bucket_name}/{prefix}):")
|
||||||
|
for obj in objects[:50]: # Limit to 50
|
||||||
|
size_kb = obj['Size'] / 1024
|
||||||
|
print(f" {obj['Key']} ({size_kb:.1f} KB)")
|
||||||
|
|
||||||
|
if len(objects) > 50:
|
||||||
|
print(f" ... and {len(objects) - 50} more")
|
||||||
|
|
||||||
|
return objects
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"❌ S3 list failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class GDriveUploader:
|
||||||
|
"""Upload scraped data to Google Drive."""
|
||||||
|
|
||||||
|
def __init__(self, credentials_file='credentials.json', token_file='token.json'):
|
||||||
|
"""
|
||||||
|
Initialize Google Drive uploader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials_file: Path to OAuth credentials JSON
|
||||||
|
token_file: Path to token JSON
|
||||||
|
"""
|
||||||
|
if not HAS_GDRIVE:
|
||||||
|
raise ImportError("Google API client not installed. Run: pip install google-api-python-client google-auth-oauthlib")
|
||||||
|
|
||||||
|
self.credentials_file = credentials_file
|
||||||
|
self.token_file = token_file
|
||||||
|
self.service = None
|
||||||
|
self._authenticate()
|
||||||
|
|
||||||
|
def _authenticate(self):
|
||||||
|
"""Authenticate with Google Drive API."""
|
||||||
|
creds = None
|
||||||
|
|
||||||
|
if os.path.exists(self.token_file):
|
||||||
|
creds = Credentials.from_authorized_user_file(self.token_file)
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
else:
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
SCOPES = ['https://www.googleapis.com/auth/drive.file']
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(self.credentials_file, SCOPES)
|
||||||
|
creds = flow.run_local_server(port=0)
|
||||||
|
|
||||||
|
with open(self.token_file, 'w') as token:
|
||||||
|
token.write(creds.to_json())
|
||||||
|
|
||||||
|
self.service = build('drive', 'v3', credentials=creds)
|
||||||
|
print("✅ Google Drive authenticated")
|
||||||
|
|
||||||
|
def create_folder(self, name, parent_id=None):
|
||||||
|
"""Create a folder in Google Drive."""
|
||||||
|
metadata = {
|
||||||
|
'name': name,
|
||||||
|
'mimeType': 'application/vnd.google-apps.folder'
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent_id:
|
||||||
|
metadata['parents'] = [parent_id]
|
||||||
|
|
||||||
|
folder = self.service.files().create(body=metadata, fields='id').execute()
|
||||||
|
return folder.get('id')
|
||||||
|
|
||||||
|
def upload_file(self, local_path, folder_id=None):
|
||||||
|
"""Upload a file to Google Drive."""
|
||||||
|
local_path = Path(local_path)
|
||||||
|
|
||||||
|
if not local_path.exists():
|
||||||
|
print(f"❌ File not found: {local_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata = {'name': local_path.name}
|
||||||
|
if folder_id:
|
||||||
|
metadata['parents'] = [folder_id]
|
||||||
|
|
||||||
|
media = MediaFileUpload(str(local_path), resumable=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file = self.service.files().create(
|
||||||
|
body=metadata,
|
||||||
|
media_body=media,
|
||||||
|
fields='id,webViewLink'
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
print(f"✅ Uploaded: {local_path.name}")
|
||||||
|
return file.get('webViewLink')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Upload failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def upload_subreddit_data(self, subreddit, prefix="r"):
|
||||||
|
"""Upload all data for a subreddit."""
|
||||||
|
data_dir = Path(f"data/{prefix}_{subreddit}")
|
||||||
|
|
||||||
|
if not data_dir.exists():
|
||||||
|
print(f"❌ Data not found for {prefix}/{subreddit}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Create folder structure
|
||||||
|
root_folder = self.create_folder(f"reddit_{prefix}_{subreddit}_{datetime.now().strftime('%Y%m%d')}")
|
||||||
|
|
||||||
|
uploaded = {}
|
||||||
|
|
||||||
|
for file_path in data_dir.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
url = self.upload_file(file_path, root_folder)
|
||||||
|
if url:
|
||||||
|
uploaded[str(file_path.name)] = url
|
||||||
|
|
||||||
|
print(f"\n📤 Uploaded {len(uploaded)} files to Google Drive")
|
||||||
|
return uploaded
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_s3(subreddit, bucket_name, prefix="r"):
|
||||||
|
"""
|
||||||
|
Convenience function to upload subreddit data to S3.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name
|
||||||
|
bucket_name: S3 bucket name
|
||||||
|
prefix: "r" or "u"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Upload results
|
||||||
|
"""
|
||||||
|
uploader = S3Uploader(bucket_name)
|
||||||
|
return uploader.upload_subreddit_data(subreddit, prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_gdrive(subreddit, prefix="r"):
|
||||||
|
"""
|
||||||
|
Convenience function to upload subreddit data to Google Drive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subreddit: Subreddit name
|
||||||
|
prefix: "r" or "u"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Upload results
|
||||||
|
"""
|
||||||
|
uploader = GDriveUploader()
|
||||||
|
return uploader.upload_subreddit_data(subreddit, prefix)
|
||||||
|
|
||||||
|
|
||||||
|
# CLI for testing
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Cloud Upload")
|
||||||
|
parser.add_argument("subreddit", help="Subreddit to upload")
|
||||||
|
parser.add_argument("--s3-bucket", help="S3 bucket name")
|
||||||
|
parser.add_argument("--gdrive", action="store_true", help="Upload to Google Drive")
|
||||||
|
parser.add_argument("--user", action="store_true", help="Is a user profile")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
prefix = "u" if args.user else "r"
|
||||||
|
|
||||||
|
if args.s3_bucket:
|
||||||
|
upload_to_s3(args.subreddit, args.s3_bucket, prefix)
|
||||||
|
elif args.gdrive:
|
||||||
|
upload_to_gdrive(args.subreddit, prefix)
|
||||||
|
else:
|
||||||
|
print("Please specify --s3-bucket or --gdrive")
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
pandas
|
pandas
|
||||||
requests
|
requests
|
||||||
|
|
||||||
|
# Async
|
||||||
|
aiohttp
|
||||||
|
aiofiles
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
streamlit
|
streamlit
|
||||||
|
|
||||||
|
|
|
||||||
2
scraper/__init__.py
Normal file
2
scraper/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Scraper module
|
||||||
|
from .async_scraper import run_async_scraper, scrape_async
|
||||||
381
scraper/async_scraper.py
Normal file
381
scraper/async_scraper.py
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"""
|
||||||
|
Async Reddit Scraper - 10x Speed Boost with aiohttp
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import aiofiles
|
||||||
|
import pandas as pd
|
||||||
|
import datetime
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
from config import USER_AGENT, MIRRORS, ASYNC_MAX_CONCURRENT, ASYNC_BATCH_SIZE
|
||||||
|
|
||||||
|
# Semaphore to limit concurrent requests
|
||||||
|
semaphore = None
|
||||||
|
|
||||||
|
async def fetch_json(session, url, retries=3):
|
||||||
|
"""Fetch JSON with retry logic."""
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.json()
|
||||||
|
elif response.status == 429: # Rate limited
|
||||||
|
await asyncio.sleep(5 * (attempt + 1))
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < retries - 1:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fetch_posts_page(session, base_url, target, after=None, is_user=False):
|
||||||
|
"""Fetch a single page of posts."""
|
||||||
|
if is_user:
|
||||||
|
path = f"/user/{target}/submitted.json"
|
||||||
|
else:
|
||||||
|
path = f"/r/{target}/new.json"
|
||||||
|
|
||||||
|
url = f"{base_url}{path}?limit=100&raw_json=1"
|
||||||
|
if after:
|
||||||
|
url += f"&after={after}"
|
||||||
|
|
||||||
|
return await fetch_json(session, url)
|
||||||
|
|
||||||
|
async def download_media_async(session, url, save_path):
|
||||||
|
"""Download media file asynchronously."""
|
||||||
|
global semaphore
|
||||||
|
|
||||||
|
if os.path.exists(save_path):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async with semaphore:
|
||||||
|
try:
|
||||||
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=60)) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
async with aiofiles.open(save_path, 'wb') as f:
|
||||||
|
async for chunk in response.content.iter_chunked(8192):
|
||||||
|
await f.write(chunk)
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def fetch_comments_async(session, permalink):
|
||||||
|
"""Fetch comments asynchronously."""
|
||||||
|
global semaphore
|
||||||
|
|
||||||
|
async with semaphore:
|
||||||
|
url = f"https://old.reddit.com{permalink}.json?limit=100"
|
||||||
|
data = await fetch_json(session, url)
|
||||||
|
|
||||||
|
if data and len(data) > 1:
|
||||||
|
return parse_comments_sync(data[1]['data']['children'], permalink)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def parse_comments_sync(comment_list, post_permalink, depth=0, max_depth=3):
|
||||||
|
"""Parse comments (sync helper)."""
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
if depth > max_depth:
|
||||||
|
return comments
|
||||||
|
|
||||||
|
for item in comment_list:
|
||||||
|
if item['kind'] != 't1':
|
||||||
|
continue
|
||||||
|
|
||||||
|
c = item['data']
|
||||||
|
comments.append({
|
||||||
|
"post_permalink": post_permalink,
|
||||||
|
"comment_id": c.get('id'),
|
||||||
|
"parent_id": c.get('parent_id'),
|
||||||
|
"author": c.get('author'),
|
||||||
|
"body": c.get('body', ''),
|
||||||
|
"score": c.get('score', 0),
|
||||||
|
"created_utc": datetime.datetime.fromtimestamp(c.get('created_utc', 0)).isoformat(),
|
||||||
|
"depth": depth,
|
||||||
|
"is_submitter": c.get('is_submitter', False),
|
||||||
|
})
|
||||||
|
|
||||||
|
replies = c.get('replies')
|
||||||
|
if replies and isinstance(replies, dict):
|
||||||
|
comments.extend(parse_comments_sync(
|
||||||
|
replies.get('data', {}).get('children', []),
|
||||||
|
post_permalink, depth + 1, max_depth
|
||||||
|
))
|
||||||
|
|
||||||
|
return comments
|
||||||
|
|
||||||
|
def extract_media_urls(post_data):
|
||||||
|
"""Extract all media URLs from a post."""
|
||||||
|
media = {"images": [], "videos": [], "galleries": []}
|
||||||
|
|
||||||
|
url = post_data.get('url', '')
|
||||||
|
|
||||||
|
if any(ext in url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
|
||||||
|
media["images"].append(url)
|
||||||
|
|
||||||
|
if 'i.redd.it' in url:
|
||||||
|
media["images"].append(url)
|
||||||
|
|
||||||
|
if post_data.get('is_video'):
|
||||||
|
reddit_video = post_data.get('media', {})
|
||||||
|
if reddit_video and 'reddit_video' in reddit_video:
|
||||||
|
video_url = reddit_video['reddit_video'].get('fallback_url', '')
|
||||||
|
if video_url:
|
||||||
|
media["videos"].append(video_url.split('?')[0])
|
||||||
|
|
||||||
|
preview = post_data.get('preview', {})
|
||||||
|
if preview and 'images' in preview:
|
||||||
|
for img in preview['images']:
|
||||||
|
source = img.get('source', {})
|
||||||
|
if source.get('url'):
|
||||||
|
media["images"].append(source['url'].replace('&', '&'))
|
||||||
|
|
||||||
|
if post_data.get('is_gallery'):
|
||||||
|
gallery_data = post_data.get('gallery_data', {})
|
||||||
|
media_metadata = post_data.get('media_metadata', {})
|
||||||
|
|
||||||
|
if gallery_data and media_metadata:
|
||||||
|
for item in gallery_data.get('items', []):
|
||||||
|
media_id = item.get('media_id')
|
||||||
|
if media_id and media_id in media_metadata:
|
||||||
|
meta = media_metadata[media_id]
|
||||||
|
if meta.get('s', {}).get('u'):
|
||||||
|
media["galleries"].append(meta['s']['u'].replace('&', '&'))
|
||||||
|
|
||||||
|
return media
|
||||||
|
|
||||||
|
def extract_post_data(p):
|
||||||
|
"""Extract post data from JSON."""
|
||||||
|
post_type = "text"
|
||||||
|
if p.get('is_video'):
|
||||||
|
post_type = "video"
|
||||||
|
elif p.get('is_gallery'):
|
||||||
|
post_type = "gallery"
|
||||||
|
elif any(ext in p.get('url', '').lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']) or 'i.redd.it' in p.get('url', ''):
|
||||||
|
post_type = "image"
|
||||||
|
elif p.get('is_self'):
|
||||||
|
post_type = "text"
|
||||||
|
else:
|
||||||
|
post_type = "link"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": p.get('id'),
|
||||||
|
"title": p.get('title'),
|
||||||
|
"author": p.get('author'),
|
||||||
|
"created_utc": datetime.datetime.fromtimestamp(p.get('created_utc', 0)).isoformat(),
|
||||||
|
"permalink": p.get('permalink'),
|
||||||
|
"url": p.get('url_overridden_by_dest', p.get('url')),
|
||||||
|
"score": p.get('score', 0),
|
||||||
|
"upvote_ratio": p.get('upvote_ratio', 0),
|
||||||
|
"num_comments": p.get('num_comments', 0),
|
||||||
|
"num_crossposts": p.get('num_crossposts', 0),
|
||||||
|
"selftext": p.get('selftext', ''),
|
||||||
|
"post_type": post_type,
|
||||||
|
"is_nsfw": p.get('over_18', False),
|
||||||
|
"is_spoiler": p.get('spoiler', False),
|
||||||
|
"flair": p.get('link_flair_text', ''),
|
||||||
|
"total_awards": p.get('total_awards_received', 0),
|
||||||
|
"has_media": p.get('is_video', False) or p.get('is_gallery', False) or 'i.redd.it' in p.get('url', ''),
|
||||||
|
"media_downloaded": False,
|
||||||
|
"source": "Async-Scraper"
|
||||||
|
}
|
||||||
|
|
||||||
|
async def scrape_async(target, limit=100, is_user=False, download_media=True, scrape_comments=True):
|
||||||
|
"""
|
||||||
|
Main async scraping function.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Subreddit or username
|
||||||
|
limit: Max posts to scrape
|
||||||
|
is_user: True if scraping a user
|
||||||
|
download_media: Download images/videos
|
||||||
|
scrape_comments: Scrape comments
|
||||||
|
"""
|
||||||
|
global semaphore
|
||||||
|
semaphore = asyncio.Semaphore(ASYNC_MAX_CONCURRENT)
|
||||||
|
|
||||||
|
prefix = "u" if is_user else "r"
|
||||||
|
print(f"🚀 ASYNC Scraper starting for {prefix}/{target}")
|
||||||
|
print(f" Target: {limit} posts | Media: {download_media} | Comments: {scrape_comments}")
|
||||||
|
print(f" Concurrency: {ASYNC_MAX_CONCURRENT} simultaneous requests")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
# Setup directories
|
||||||
|
base_dir = f"data/{prefix}_{target}"
|
||||||
|
media_dir = f"{base_dir}/media"
|
||||||
|
images_dir = f"{media_dir}/images"
|
||||||
|
videos_dir = f"{media_dir}/videos"
|
||||||
|
|
||||||
|
for d in [base_dir, media_dir, images_dir, videos_dir]:
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
all_posts = []
|
||||||
|
all_comments = []
|
||||||
|
media_tasks = []
|
||||||
|
seen_permalinks = set()
|
||||||
|
|
||||||
|
# Load existing data
|
||||||
|
posts_file = f"{base_dir}/posts.csv"
|
||||||
|
if os.path.exists(posts_file):
|
||||||
|
try:
|
||||||
|
df = pd.read_csv(posts_file)
|
||||||
|
seen_permalinks = set(df['permalink'].astype(str).tolist())
|
||||||
|
print(f"📚 Loaded {len(seen_permalinks)} existing posts")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(headers={"User-Agent": USER_AGENT}) as session:
|
||||||
|
after = None
|
||||||
|
total_fetched = 0
|
||||||
|
|
||||||
|
while total_fetched < limit:
|
||||||
|
# Try mirrors
|
||||||
|
mirrors = MIRRORS.copy()
|
||||||
|
random.shuffle(mirrors)
|
||||||
|
|
||||||
|
data = None
|
||||||
|
for mirror in mirrors:
|
||||||
|
data = await fetch_posts_page(session, mirror, target, after, is_user)
|
||||||
|
if data:
|
||||||
|
print(f"✅ Fetched from {mirror}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
print("❌ All mirrors failed")
|
||||||
|
break
|
||||||
|
|
||||||
|
children = data.get('data', {}).get('children', [])
|
||||||
|
if not children:
|
||||||
|
print("🏁 No more posts")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f" Processing {len(children)} posts...")
|
||||||
|
|
||||||
|
# Process posts
|
||||||
|
batch_posts = []
|
||||||
|
comment_tasks = []
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
p = child['data']
|
||||||
|
post = extract_post_data(p)
|
||||||
|
|
||||||
|
if post['permalink'] in seen_permalinks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_permalinks.add(post['permalink'])
|
||||||
|
batch_posts.append(post)
|
||||||
|
|
||||||
|
# Queue media downloads
|
||||||
|
if download_media:
|
||||||
|
media = extract_media_urls(p)
|
||||||
|
|
||||||
|
for i, img_url in enumerate(media['images'][:5]):
|
||||||
|
ext = os.path.splitext(urlparse(img_url).path)[1] or '.jpg'
|
||||||
|
save_path = f"{images_dir}/{post['id']}_{i}{ext}"
|
||||||
|
media_tasks.append(download_media_async(session, img_url, save_path))
|
||||||
|
|
||||||
|
for i, img_url in enumerate(media['galleries'][:10]):
|
||||||
|
save_path = f"{images_dir}/{post['id']}_gallery_{i}.jpg"
|
||||||
|
media_tasks.append(download_media_async(session, img_url, save_path))
|
||||||
|
|
||||||
|
for i, vid_url in enumerate(media['videos'][:2]):
|
||||||
|
if 'youtube' not in vid_url:
|
||||||
|
save_path = f"{videos_dir}/{post['id']}_{i}.mp4"
|
||||||
|
media_tasks.append(download_media_async(session, vid_url, save_path))
|
||||||
|
|
||||||
|
# Queue comment fetching
|
||||||
|
if scrape_comments and post['num_comments'] > 0:
|
||||||
|
comment_tasks.append(fetch_comments_async(session, post['permalink']))
|
||||||
|
|
||||||
|
all_posts.extend(batch_posts)
|
||||||
|
total_fetched += len(batch_posts)
|
||||||
|
|
||||||
|
# Fetch comments in parallel
|
||||||
|
if comment_tasks:
|
||||||
|
print(f" 💬 Fetching comments for {len(comment_tasks)} posts...")
|
||||||
|
comment_results = await asyncio.gather(*comment_tasks, return_exceptions=True)
|
||||||
|
for result in comment_results:
|
||||||
|
if isinstance(result, list):
|
||||||
|
all_comments.extend(result)
|
||||||
|
|
||||||
|
print(f" 📊 Progress: {total_fetched}/{limit} posts | {len(all_comments)} comments")
|
||||||
|
|
||||||
|
after = data.get('data', {}).get('after')
|
||||||
|
if not after:
|
||||||
|
print("🏁 Reached end of available posts")
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(1) # Small delay between pages
|
||||||
|
|
||||||
|
# Download all media in parallel
|
||||||
|
if media_tasks:
|
||||||
|
print(f"\n🖼️ Downloading {len(media_tasks)} media files in parallel...")
|
||||||
|
media_results = await asyncio.gather(*media_tasks, return_exceptions=True)
|
||||||
|
downloaded = sum(1 for r in media_results if r is True)
|
||||||
|
print(f" ✅ Downloaded {downloaded}/{len(media_tasks)} files")
|
||||||
|
|
||||||
|
# Save data
|
||||||
|
if all_posts:
|
||||||
|
df = pd.DataFrame(all_posts)
|
||||||
|
if os.path.exists(posts_file):
|
||||||
|
df.to_csv(posts_file, mode='a', header=False, index=False)
|
||||||
|
else:
|
||||||
|
df.to_csv(posts_file, index=False)
|
||||||
|
print(f"\n💾 Saved {len(all_posts)} posts to {posts_file}")
|
||||||
|
|
||||||
|
if all_comments:
|
||||||
|
comments_file = f"{base_dir}/comments.csv"
|
||||||
|
df = pd.DataFrame(all_comments)
|
||||||
|
if os.path.exists(comments_file):
|
||||||
|
df.to_csv(comments_file, mode='a', header=False, index=False)
|
||||||
|
else:
|
||||||
|
df.to_csv(comments_file, index=False)
|
||||||
|
print(f"💾 Saved {len(all_comments)} comments")
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("✅ ASYNC SCRAPE COMPLETE!")
|
||||||
|
print(f" 📊 Posts: {len(all_posts)}")
|
||||||
|
print(f" 💬 Comments: {len(all_comments)}")
|
||||||
|
print(f" 🖼️ Media: {len(media_tasks)} queued")
|
||||||
|
print(f" ⏱️ Duration: {duration:.1f}s")
|
||||||
|
print(f" ⚡ Speed: {len(all_posts) / duration:.1f} posts/sec")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'posts': len(all_posts),
|
||||||
|
'comments': len(all_comments),
|
||||||
|
'duration': duration
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_async_scraper(target, limit=100, is_user=False, download_media=True, scrape_comments=True):
|
||||||
|
"""Wrapper to run async scraper from sync code."""
|
||||||
|
return asyncio.run(scrape_async(target, limit, is_user, download_media, scrape_comments))
|
||||||
|
|
||||||
|
# CLI for testing
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Async Reddit Scraper")
|
||||||
|
parser.add_argument("target", help="Subreddit or username")
|
||||||
|
parser.add_argument("--limit", type=int, default=100)
|
||||||
|
parser.add_argument("--user", action="store_true")
|
||||||
|
parser.add_argument("--no-media", action="store_true")
|
||||||
|
parser.add_argument("--no-comments", action="store_true")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
run_async_scraper(
|
||||||
|
args.target,
|
||||||
|
args.limit,
|
||||||
|
args.user,
|
||||||
|
not args.no_media,
|
||||||
|
not args.no_comments
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue