376 lines
12 KiB
Python
Executable File
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()
|