""" 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()