Files
scripts/scraper/batch_scraper.py

376 lines
12 KiB
Python
Executable File

"""
batch_scraper.py - Scraper em batch com suporte a múltiplos sites
Author: Descomplicar® Crescimento Digital
Link: https://descomplicar.pt
Copyright: 2025 Descomplicar®
"""
import os
import json
import logging
import argparse
from pathlib import Path
from typing import List, Dict, Optional
from datetime import datetime
from scraper import Scraper, ScraperConfig
from reddit_scraper import RedditScraper
# Logging será configurado após carregar config (precisa do caminho dos logs)
class BatchScraper:
def __init__(self, config_file: str = "sites_config.json"):
"""
Inicializa o batch scraper.
Args:
config_file: Caminho para ficheiro de configuração JSON
"""
self.config_file = config_file
self.config = self.load_config()
# Configurar diretórios de output
self.setup_directories()
# Configurar logging com caminho correto
self.setup_logging()
self.results = {
"started_at": datetime.now().isoformat(),
"total_sites": 0,
"successful": 0,
"failed": 0,
"sites": []
}
def load_config(self) -> Dict:
"""Carrega configuração do ficheiro JSON."""
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
print(f"[INFO] Configuração carregada de {self.config_file}")
return config
except FileNotFoundError:
print(f"[ERROR] Ficheiro de configuração não encontrado: {self.config_file}")
raise
except json.JSONDecodeError as e:
print(f"[ERROR] Erro ao ler JSON: {e}")
raise
def setup_directories(self):
"""Configura e cria diretórios de output baseado na configuração."""
# Obter diretório base (padrão: diretório atual)
base_dir = self.config.get('output_base_dir', '.')
# Obter estrutura de subdiretórios
output_dirs = self.config.get('output_dirs', {
'raw': 'output_md',
'cleaned': 'output_cleaned',
'formatted': 'formatted',
'logs': 'logs'
})
# Criar Path objects
self.base_path = Path(base_dir)
self.raw_dir = self.base_path / output_dirs['raw']
self.cleaned_dir = self.base_path / output_dirs['cleaned']
self.formatted_dir = self.base_path / output_dirs['formatted']
self.logs_dir = self.base_path / output_dirs['logs']
# Criar diretórios se não existirem
for directory in [self.raw_dir, self.cleaned_dir, self.formatted_dir, self.logs_dir]:
directory.mkdir(parents=True, exist_ok=True)
print(f"[INFO] Diretórios configurados:")
print(f" • Base: {self.base_path}")
print(f" • Raw: {self.raw_dir}")
print(f" • Logs: {self.logs_dir}")
def setup_logging(self):
"""Configura logging com caminho correto para logs."""
log_file = self.logs_dir / f'batch_scraper_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file, encoding='utf-8'),
logging.StreamHandler()
],
force=True # Force reconfiguration
)
logging.info(f"Logging configurado: {log_file}")
def scrape_websites(self, site_types: Optional[List[str]] = None):
"""
Scrape todos os websites da configuração.
Args:
site_types: Lista de tipos para filtrar (wordpress, forum, ecommerce)
Se None, processa todos
"""
sites = self.config.get('sites', [])
# Filtrar por tipo se especificado
if site_types:
sites = [s for s in sites if s.get('type') in site_types]
if not sites:
logging.warning("Nenhum site para processar")
return
logging.info(f"Processando {len(sites)} sites...")
self.results['total_sites'] = len(sites)
for site in sites:
try:
self._scrape_single_site(site)
except Exception as e:
logging.error(f"Erro ao processar {site.get('name', 'unknown')}: {e}")
self.results['failed'] += 1
self.results['sites'].append({
"name": site.get('name'),
"url": site.get('url'),
"status": "failed",
"error": str(e)
})
logging.info(
f"Batch concluído: {self.results['successful']}/{self.results['total_sites']} "
f"sites processados com sucesso"
)
def _scrape_single_site(self, site: Dict):
"""Processa um único site."""
name = site.get('name', 'Unknown')
url = site.get('url')
site_type = site.get('type', 'website')
max_depth = site.get('max_depth', 2)
logging.info(f"Iniciando scraping de {name} ({url})")
# Configuração específica para o site
config = ScraperConfig(
max_depth=max_depth,
request_timeout=60,
max_retries=3,
politeness_delay=(2, 5),
output_dir=str(self.raw_dir), # Usar diretório configurado
excluded_patterns=[
'/tag/', '/category/', '/author/', '/page/',
'/wp-content/', '/wp-admin/', '/feed/', '/rss/',
'/login', '/register', '/cart', '/checkout'
],
save_metadata=True,
clean_output=True
)
# Ajustes por tipo de site
if site_type == 'forum':
config.max_depth = 1 # Fóruns: apenas 1 nível
config.politeness_delay = (3, 6) # Mais respeitoso
elif site_type == 'ecommerce':
config.max_depth = 1 # E-commerce: só recursos/blog
config.excluded_patterns.extend([
'/product/', '/shop/', '/store/', '/buy/'
])
# Executar scraping
scraper = Scraper(config)
try:
scraper.crawl(url)
self.results['successful'] += 1
self.results['sites'].append({
"name": name,
"url": url,
"type": site_type,
"status": "success",
"pages_scraped": len(scraper.visited),
"pages_failed": len(scraper.failed_urls)
})
logging.info(f"{name} concluído: {len(scraper.visited)} páginas extraídas")
except Exception as e:
logging.error(f"Erro ao scrape {name}: {e}")
raise
def scrape_reddit(self):
"""Processa subreddits Reddit."""
subreddits = self.config.get('reddit_subreddits', [])
if not subreddits:
logging.warning("Nenhum subreddit configurado")
return
try:
logging.info(f"Processando {len(subreddits)} subreddits Reddit...")
reddit_scraper = RedditScraper(output_dir=str(self.raw_dir)) # Usar diretório configurado
reddit_scraper.scrape_multiple_subreddits(
subreddits,
limit_per_sub=50,
sort_by="top",
time_filter="year"
)
self.results['reddit'] = {
"status": "success",
"subreddits": subreddits,
"count": len(subreddits)
}
logging.info("✓ Reddit scraping concluído")
except Exception as e:
logging.error(f"Erro no Reddit scraping: {e}")
self.results['reddit'] = {
"status": "failed",
"error": str(e)
}
def save_report(self):
"""Guarda relatório final do batch."""
self.results['finished_at'] = datetime.now().isoformat()
report_file = self.logs_dir / f"batch_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(report_file, 'w', encoding='utf-8') as f:
json.dump(self.results, f, indent=2, ensure_ascii=False)
logging.info(f"Relatório guardado em: {report_file}")
# Resumo no terminal
print("\n" + "="*60)
print("RESUMO DO BATCH SCRAPING")
print("="*60)
print(f"Sites processados: {self.results['successful']}/{self.results['total_sites']}")
print(f"Tempo total: {self._calculate_duration()}")
print(f"Relatório completo: {report_file}")
print("="*60 + "\n")
def _calculate_duration(self) -> str:
"""Calcula duração do processo."""
try:
start = datetime.fromisoformat(self.results['started_at'])
end = datetime.fromisoformat(self.results['finished_at'])
duration = end - start
hours = duration.seconds // 3600
minutes = (duration.seconds % 3600) // 60
seconds = duration.seconds % 60
if hours > 0:
return f"{hours}h {minutes}m {seconds}s"
elif minutes > 0:
return f"{minutes}m {seconds}s"
else:
return f"{seconds}s"
except:
return "N/A"
def main():
"""Função principal com argumentos CLI."""
parser = argparse.ArgumentParser(
description='Batch scraper para múltiplos sites',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Exemplos de uso:
# Processar todos os sites
python batch_scraper.py --all
# Apenas sites WordPress
python batch_scraper.py --types wordpress
# Apenas fóruns
python batch_scraper.py --types forum
# Múltiplos tipos
python batch_scraper.py --types wordpress forum
# Apenas Reddit
python batch_scraper.py --reddit-only
# Tudo (sites + Reddit)
python batch_scraper.py --all --include-reddit
# Usar config alternativo
python batch_scraper.py --config meu_config.json --all
"""
)
parser.add_argument(
'--config',
default='sites_config.json',
help='Caminho para ficheiro de configuração (padrão: sites_config.json)'
)
parser.add_argument(
'--all',
action='store_true',
help='Processar todos os sites'
)
parser.add_argument(
'--types',
nargs='+',
choices=['wordpress', 'forum', 'ecommerce', 'website'],
help='Tipos de sites para processar'
)
parser.add_argument(
'--reddit-only',
action='store_true',
help='Processar apenas subreddits Reddit'
)
parser.add_argument(
'--include-reddit',
action='store_true',
help='Incluir Reddit além dos sites'
)
args = parser.parse_args()
# Validar argumentos
if not (args.all or args.types or args.reddit_only):
parser.error("Especifica --all, --types ou --reddit-only")
# Criar batch scraper
batch = BatchScraper(config_file=args.config)
try:
# Processar Reddit se solicitado
if args.reddit_only:
batch.scrape_reddit()
# Processar websites
elif args.all or args.types:
batch.scrape_websites(site_types=args.types)
# Incluir Reddit se solicitado
if args.include_reddit:
batch.scrape_reddit()
# Guardar relatório
batch.save_report()
except KeyboardInterrupt:
logging.warning("\nProcesso interrompido pelo utilizador")
batch.save_report()
except Exception as e:
logging.error(f"Erro crítico: {e}")
batch.save_report()
raise
if __name__ == "__main__":
main()