A extração de dados da web frequentemente envolve desafios como conteúdo dinâmico carregado via JavaScript e mecanismos de anti-raspagem. Este guia detalha a construção de um rastreador robusto utilizando o framework Scrapy em conjunto com o Selenium para lidar com páginas dinâmicas, especificamente visando a lista Top 250 de filmes do Douban. Além disso, implementaremos pipelines customizados para persistência de dados em MySQL e Redis, juntamente com middlewares para rotação de proxies e User-Agents, garantindo uma coleta eficiente e furtiva.
Definição dos Modelos de Dados (items.py)
Os modelos de dados definem a estrutura das informações que serão extraídas. Serão criados dois itens principais: um para os detalhes dos filmes e outro para os comentários dos usuários.
# -*- coding: utf-8 -*-
import scrapy
from scrapy import Field
class ItemFilme(scrapy.Item):
# Campos para os detalhes do filme
titulo = Field()
id_filme = Field()
url_poster = Field()
url_detalhes = Field()
pontuacao_media = Field()
duracao = Field()
data_lancamento = Field()
diretor = Field()
atores_principais = Field()
pais_producao = Field()
roteirista = Field()
numero_avaliacoes = Field()
sinopse = Field()
genero = Field()
idioma = Field()
titulo_alternativo = Field()
classificacao_rank = Field()
url_comentarios = Field()
tipo_registro = Field() # Para diferenciar entre item de filme e item de comentário
class ItemComentario(scrapy.Item):
# Campos para os comentários dos filmes
nome_usuario = Field()
titulo_filme = Field()
id_filme = Field()
texto_comentario = Field()
data_comentario = Field()
estrelas_avaliacao = Field()
taxa_positivo = Field()
taxa_neutro = Field()
taxa_negativo = Field()
url_avatar_usuario = Field()
url_comentarios = Field()
url_poster_filme = Field()
url_detalhes_filme = Field()
tipo_registro = Field() # Para diferenciar entre item de filme e item de comentário
Lógica do Rastreador (spiders/spider_filmes.py)
O rastreador será responsável por navegar pelas páginas, extrair dados estáticos com Scrapy e interagir com elementos dinâmicos usando Selenium.
# -*- coding: utf-8 -*-
import scrapy
import logging
from scrapy.spiders import Rule, CrawlSpider
from scrapy.linkextractors import LinkExtractor
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException
from ..items import ItemFilme, ItemComentario
class AranhaFilmesDouban(CrawlSpider):
name = 'aranha_filmes_douban'
start_urls = ['https://movie.douban.com/top250'] # Ponto de partida
# Configurações personalizadas para o rastreador
custom_settings = {
"COOKIES_ENABLED": False,
"DOWNLOAD_DELAY": 3,
'DEFAULT_REQUEST_HEADERS': {
'Accept': "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
'Accept-Encoding': "gzip, deflate, sdch",
'Accept-Language': "pt-BR,pt;q=0.8,en-US;q=0.6,en;q=0.4",
'Cache-Control': "no-cache",
'Connection': "keep-alive",
'Host': "movie.douban.com",
'Pragma': "no-cache",
'Upgrade-Insecure-Requests': "1",
}
}
# Regras de rastreamento para seguir links de detalhes de filmes
rules = (
Rule(LinkExtractor(allow=r'^https://movie.douban.com/subject/\d+/$'), callback='parse_detalhes_filme', follow=True),
)
def __init__(self, *args, **kwargs):
super(AranhaFilmesDouban, self).__init__(*args, **kwargs)
# Inicializa o driver PhantomJS (ou outro driver, como Chrome Headless)
# Nota: PhantomJS está obsoleto; considere usar Chrome/Firefox Headless.
self.driver = webdriver.PhantomJS(executable_path=r'D:\phantomjs-2.1.1-windows\bin\phantomjs.exe')
self.logger.setLevel(logging.INFO) # Configura o nível de log do rastreador
def __del__(self):
"""Fecha o driver do Selenium ao finalizar o rastreador."""
if self.driver:
self.driver.quit()
def start_requests(self):
"""Gera as requisições iniciais para as páginas do Top 250."""
for offset in range(0, 250, 25):
url_top = f'https://movie.douban.com/top250?start={offset}&filter='
yield scrapy.Request(url_top, callback=self.parse_top_list)
def parse_top_list(self, response):
"""Extrai os links dos filmes da lista Top 250."""
# Extrai os links dos detalhes dos filmes da página da lista
movie_links = response.xpath('//div[@class="hd"]/a/@href').extract()
for link in movie_links:
yield scrapy.Request(link, callback=self.parse_detalhes_filme)
def parse_detalhes_filme(self, response):
"""Processa a página de detalhes de um filme."""
self.logger.info(f'Processando URL de detalhes: {response.url}')
# Limpa o corpo da resposta para facilitar a análise
corpo_limpo = response.body.decode("utf-8").replace('\t', '').replace('\n', '')
response = response.replace(body=corpo_limpo.encode("utf-8"))
item_filme = ItemFilme()
# Extrai o ID do filme da URL
partes_url = response.url.split('/')
item_filme['id_filme'] = partes_url[-2] if partes_url[-1] == '' else partes_url[-1]
# Define URLs locais para demonstração (ajustar conforme ambiente)
item_filme['url_detalhes'] = f'http://localhost:8000/movie/{item_filme["id_filme"]}/'
# Extração de dados estáticos com Scrapy XPath
item_filme['sinopse'] = ''.join(response.xpath('//*[@id="link-report"]/span[1]/text() | //*[@id="link-report"]/span[1]/span/text()').extract()).strip().replace('\n', '').replace('\t', '')
item_filme['classificacao_rank'] = response.xpath('//*[@id="content"]/div[1]/span[1]/text()').extract_first()
item_filme['titulo'] = response.xpath('//*[@id="content"]/h1/span[1]/text()').extract_first()
item_filme['url_poster'] = response.xpath('//*[@id="mainpic"]/a/img/@src').extract_first().replace('.webp', '.jpg')
item_filme['pontuacao_media'] = response.xpath('//*[@id="interest_sectl"]/div[1]/div[2]/strong/text()').extract_first().strip()
# Chama Selenium para conteúdo dinâmico (informações adicionais e número de avaliações)
self.coletar_com_selenium(item_filme, response.url)
# Prepara requisição para a página de comentários
url_douban_comentarios = f'https://movie.douban.com/subject/{item_filme["id_filme"]}/comments'
item_filme['url_comentarios'] = f'http://localhost:8000/movie/{item_filme["id_filme"]}/comments'
item_filme['tipo_registro'] = 'filme'
# Passa os dados do filme para a requisição de comentários
request_comentarios = scrapy.Request(url_douban_comentarios, callback=self.parse_comentarios)
request_comentarios.meta['id_filme'] = item_filme['id_filme']
request_comentarios.meta['titulo_filme'] = item_filme['titulo']
request_comentarios.meta['url_comentarios'] = item_filme['url_comentarios']
request_comentarios.meta['url_poster_filme'] = item_filme['url_poster']
request_comentarios.meta['url_detalhes_filme'] = item_filme['url_detalhes']
yield request_comentarios
yield item_filme
def parse_comentarios(self, response):
"""Processa a página de comentários de um filme."""
self.logger.info(f'Processando URL de comentários: {response.url}')
corpo_limpo = response.body.decode("utf-8").replace('\t', '').replace('\n', '')
response = response.replace(body=corpo_limpo.encode("utf-8"))
item_comentario = ItemComentario()
# Extrai taxas de avaliação (positivo, neutro, negativo)
item_comentario['taxa_positivo'] = response.xpath('//*[@id="content"]/div/div[1]/div[3]/label[2]/span[2]/text()').extract_first()
item_comentario['taxa_neutro'] = response.xpath('//*[@id="content"]/div/div[1]/div[3]/label[3]/span[2]/text()').extract_first()
item_comentario['taxa_negativo'] = response.xpath('//*[@id="content"]/div/div[1]/div[3]/label[4]/span[2]/text()').extract_first()
# Extração de detalhes dos comentários
comentarios_container = response.xpath('//*[@id="comments"]')[0]
# Conteúdo dos comentários
textos_comentarios = [
t.strip().replace('\n', '').replace('\t', '')
for t in comentarios_container.xpath('./div/div[@class="comment"]/p/span/text()').extract()
if t.strip() != ''
]
item_comentario['texto_comentario'] = textos_comentarios
# Nomes dos usuários
item_comentario['nome_usuario'] = comentarios_container.xpath('./div/div[2]/h3/span[2]/a/text()').extract()
# Datas dos comentários
item_comentario['data_comentario'] = [
t.strip() for t in comentarios_container.xpath('./div/div[2]/h3/span[2]/span[3]/text()').extract()
]
# Nível de estrelas da avaliação
item_comentario['estrelas_avaliacao'] = comentarios_container.xpath('./div/div[2]/h3/span[2]/span[2]/@class').extract()
# URLs dos avatares dos usuários
item_comentario['url_avatar_usuario'] = comentarios_container.xpath('./div/div/a/img/@src').extract()
# Atribui metadados do filme ao item de comentário
item_comentario['id_filme'] = response.meta['id_filme']
item_comentario['titulo_filme'] = response.meta['titulo_filme']
item_comentario['url_comentarios'] = response.meta['url_comentarios']
item_comentario['url_poster_filme'] = response.meta['url_poster_filme']
item_comentario['url_detalhes_filme'] = response.meta['url_detalhes_filme']
item_comentario['tipo_registro'] = 'comentario'
yield item_comentario
def coletar_com_selenium(self, item_filme, url_pagina):
"""Usa Selenium para extrair informações que são carregadas dinamicamente."""
try:
self.driver.get(url_pagina)
self.driver.implicitly_wait(10) # Espera implícita por elementos
# Localizador para o bloco de informações principais
locator_info = ('xpath', '//div[@class="subject clearfix"]/div[2]')
dados_brutos = self.obter_texto_elemento(locator_info)
if dados_brutos:
for linha in dados_brutos.split('\n'):
if ':' in linha:
chave, valor = linha.split(':', 1)
chave = chave.strip()
valor = valor.strip()
if 'Diretor' in chave:
item_filme['diretor'] = valor
elif 'Roteirista' in chave:
item_filme['roteirista'] = valor
elif 'Atores principais' in chave:
item_filme['atores_principais'] = valor
elif 'Gênero' in chave:
item_filme['genero'] = valor
elif 'País de produção' in chave:
item_filme['pais_producao'] = valor
elif 'Idioma' in chave:
item_filme['idioma'] = valor
elif 'Data de lançamento' in chave:
item_filme['data_lancamento'] = valor
elif 'Duração' in chave:
item_filme['duracao'] = valor
elif 'Também conhecido como' in chave:
item_filme['titulo_alternativo'] = valor
# Localizador para o número de avaliações
locator_num_avaliacoes = ('xpath', '//*[@id="interest_sectl"]/div[1]/div[2]/div/div[2]/a/span')
item_filme['numero_avaliacoes'] = self.obter_texto_elemento(locator_num_avaliacoes)
except TimeoutException:
self.logger.error(f"Timeout ao carregar a página com Selenium: {url_pagina}")
except WebDriverException as e:
self.logger.error(f"Erro do WebDriver ao acessar {url_pagina}: {e}")
except Exception as e:
self.logger.error(f"Erro inesperado no Selenium para {url_pagina}: {e}")
def encontrar_elemento(self, localizador, tempo_limite=5):
"""Localiza um elemento no DOM usando WebDriverWait."""
try:
elemento = WebDriverWait(self.driver, tempo_limite).until(EC.presence_of_element_located(localizador))
return elemento
except TimeoutException:
self.logger.warning(f"Elemento não encontrado dentro do tempo limite: {localizador}")
return None
except Exception as e:
self.logger.error(f"Erro ao encontrar elemento {localizador}: {e}")
return None
def obter_texto_elemento(self, localizador):
"""Obtém o texto de um elemento localizado."""
elemento = self.encontrar_elemento(localizador)
return elemento.text if elemento else ''
Pipelines de Itens (pipelines.py)
Os pipelines são usados para processar os itens após a raspgaem, como validação, limpeza e armazenamento em banco de dados.
# -*- coding: utf-8 -*-
import pymysql
import time
import redis
import json
from scrapy.exceptions import DropItem
from scrapy import log # Usar logging do Scrapy ou Python padrão
import logging
class PipelineValidacaoDados(object):
"""Pipeline para validação e limpeza inicial dos itens."""
def process_item(self, item, spider):
if spider.name == 'aranha_filmes_douban':
# Verifica campos essenciais para itens de filme
if item['tipo_registro'] == 'filme':
if not item.get('url_poster'):
raise DropItem(f"Item de filme {item.get('titulo', 'N/A')} sem URL de poster.")
if not item.get('titulo'):
raise DropItem(f"Item de filme com ID {item.get('id_filme', 'N/A')} sem título.")
# Preenche campos opcionais com 'N/A' se estiverem ausentes
item.setdefault('titulo_alternativo', 'N/A')
item.setdefault('genero', 'N/A')
item.setdefault('duracao', 'N/A')
if item.get('genero') and 'Documentário' in item['genero']: # Ajuste para documentários
item.setdefault('atores_principais', 'N/A')
# Nenhuma validação específica para comentários aqui, apenas para filmes.
return item
class PipelineRedis(object):
"""Pipeline para armazenar dados em Redis (exemplo simples)."""
def __init__(self):
self.cliente_redis = redis.Redis(host='localhost', port=6379, db=0)
def process_item(self, item, spider):
# Exemplo: armazena o ID do filme em um conjunto Redis
if spider.name == 'aranha_filmes_douban' and item['tipo_registro'] == 'filme':
self.cliente_redis.sadd(f'filmes:{spider.name}', item['id_filme'])
# Poderia armazenar o item completo serializado em JSON também
# self.cliente_redis.set(f'filme:{item["id_filme"]}', json.dumps(dict(item)))
return item
class PipelineMySQL(object):
"""Pipeline para armazenamento de itens em um banco de dados MySQL."""
def __init__(self):
self.conn = None
self.cursor = None
self.estrelas_formatadas = [] # Lista para armazenar as estrelas formatadas
def open_spider(self, spider):
"""Estabelece a conexão com o banco de dados MySQL."""
self.conn = pymysql.connect(
host='localhost',
port=3306,
user='root',
password='123456', # Mude para sua senha
db='db_douban_scraping',
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
self.cursor = self.conn.cursor()
logging.info("Conexão com MySQL estabelecida.")
if spider.name == 'aranha_filmes_douban':
# Limpa as tabelas antes de iniciar a raspagem para evitar duplicatas em novas execuções
self.limpar_dados_tabela('filmes_detalhes')
self.limpar_dados_tabela('filmes_comentarios')
self.limpar_dados_tabela('filmes_resumo')
logging.info("Tabelas MySQL inicializadas.")
def close_spider(self, spider):
"""Fecha a conexão com o banco de dados MySQL."""
if self.cursor:
self.cursor.close()
if self.conn:
self.conn.close()
logging.info("Conexão com MySQL encerrada.")
def limpar_dados_tabela(self, nome_tabela):
"""Limpa todos os registros de uma tabela."""
try:
sql_count = f"SELECT COUNT(*) FROM `{nome_tabela}`"
self.cursor.execute(sql_count)
if self.cursor.fetchone()['COUNT(*)'] > 0:
sql_delete = f"DELETE FROM `{nome_tabela}`"
self.cursor.execute(sql_delete)
self.conn.commit()
logging.info(f"Tabela '{nome_tabela}' limpa com sucesso.")
else:
logging.info(f"Tabela '{nome_tabela}' já está vazia.")
except Exception as e:
logging.error(f"Erro ao limpar tabela '{nome_tabela}': {e}")
self.conn.rollback()
def verificar_existencia_id(self, id_valor, nome_tabela, campo_id):
"""Verifica se um ID já existe em uma tabela."""
sql = f"SELECT 1 FROM `{nome_tabela}` WHERE `{campo_id}` = %s LIMIT 1"
self.cursor.execute(sql, (id_valor,))
return self.cursor.fetchone() is not None
def process_item(self, item, spider):
if spider.name == 'aranha_filmes_douban':
if item['tipo_registro'] == 'filme':
self._inserir_filme_detalhes(item)
elif item['tipo_registro'] == 'comentario':
self._inserir_comentarios(item)
self._inserir_filme_resumo(item)
return item
def _inserir_filme_detalhes(self, filme_item):
"""Insere ou atualiza os detalhes de um filme na tabela `filmes_detalhes`."""
sql_insert = """
INSERT INTO `filmes_detalhes`(`id_filme`, `titulo`, `url_poster`, `url_detalhes`, `pontuacao_media`,
`duracao`, `data_lancamento`, `diretor`, `atores_principais`, `pais_producao`, `roteirista`,
`numero_avaliacoes`, `sinopse`, `genero`, `idioma`, `titulo_alternativo`, `classificacao_rank`,
`url_comentarios`)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
titulo=VALUES(titulo), url_poster=VALUES(url_poster), url_detalhes=VALUES(url_detalhes),
pontuacao_media=VALUES(pontuacao_media), duracao=VALUES(duracao), data_lancamento=VALUES(data_lancamento),
diretor=VALUES(diretor), atores_principais=VALUES(atores_principais), pais_producao=VALUES(pais_producao),
roteirista=VALUES(roteirista), numero_avaliacoes=VALUES(numero_avaliacoes), sinopse=VALUES(sinopse),
genero=VALUES(genero), idioma=VALUES(idioma), titulo_alternativo=VALUES(titulo_alternativo),
classificacao_rank=VALUES(classificacao_rank), url_comentarios=VALUES(url_comentarios);
"""
try:
self.cursor.execute(sql_insert, (
filme_item.get('id_filme'), filme_item.get('titulo'), filme_item.get('url_poster'),
filme_item.get('url_detalhes'), filme_item.get('pontuacao_media'), filme_item.get('duracao'),
filme_item.get('data_lancamento'), filme_item.get('diretor'), filme_item.get('atores_principais'),
filme_item.get('pais_producao'), filme_item.get('roteirista'), filme_item.get('numero_avaliacoes'),
filme_item.get('sinopse'), filme_item.get('genero'), filme_item.get('idioma'),
filme_item.get('titulo_alternativo'), filme_item.get('classificacao_rank'),
filme_item.get('url_comentarios')
))
self.conn.commit()
logging.info(f"Detalhes do filme '{filme_item.get('titulo')}' (ID: {filme_item.get('id_filme')}) salvos/atualizados.")
except Exception as e:
logging.error(f"Erro ao salvar detalhes do filme '{filme_item.get('titulo')}': {e}")
self.conn.rollback()
def _inserir_comentarios(self, comentario_item):
"""Insere os comentários de um filme na tabela `filmes_comentarios`."""
if comentario_item.get('estrelas_avaliacao'):
self.estrelas_formatadas = self.formatar_estrelas(comentario_item['estrelas_avaliacao'])
sql_insert = """
INSERT INTO `filmes_comentarios`(`id_filme`, `titulo_filme`, `nome_usuario`, `estrelas_avaliacao`,
`texto_comentario`, `data_comentario`, `url_comentarios`, `url_avatar_usuario`)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s);
"""
for i in range(len(comentario_item['texto_comentario'])):
try:
self.cursor.execute(sql_insert, (
comentario_item.get('id_filme'),
comentario_item.get('titulo_filme'),
comentario_item['nome_usuario'][i] if i < len(comentario_item['nome_usuario']) else 'Anônimo',
self.estrelas_formatadas[i] if i < len(self.estrelas_formatadas) else 'N/A',
comentario_item['texto_comentario'][i],
comentario_item['data_comentario'][i].strip(),
comentario_item.get('url_comentarios'),
comentario_item['url_avatar_usuario'][i] if i < len(comentario_item['url_avatar_usuario']) else 'N/A'
))
except Exception as e:
logging.error(f"Erro ao salvar comentário para o filme '{comentario_item.get('titulo_filme')}': {e}")
# Continua para o próximo comentário mesmo que um falhe
self.conn.commit()
logging.info(f"Comentários para o filme '{comentario_item.get('titulo_filme')}' (ID: {comentario_item.get('id_filme')}) salvos.")
def _inserir_filme_resumo(self, comentario_item):
"""Insere informações resumidas do filme na tabela `filmes_resumo`."""
if not self.verificar_existencia_id(comentario_item['id_filme'], 'filmes_resumo', 'id_filme'):
sql_insert = """
INSERT INTO `filmes_resumo`(`id_filme`, `titulo_filme`, `taxa_positivo`, `taxa_neutro`,
`taxa_negativo`, `url_comentarios`, `url_poster_filme`, `url_detalhes_filme`)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s);
"""
try:
self.cursor.execute(sql_insert, (
comentario_item.get('id_filme'), comentario_item.get('titulo_filme'),
comentario_item.get('taxa_positivo'), comentario_item.get('taxa_neutro'),
comentario_item.get('taxa_negativo'), comentario_item.get('url_comentarios'),
comentario_item.get('url_poster_filme'), comentario_item.get('url_detalhes_filme')
))
self.conn.commit()
logging.info(f"Resumo do filme '{comentario_item.get('titulo_filme')}' (ID: {comentario_item.get('id_filme')}) salvos.")
except Exception as e:
logging.error(f"Erro ao salvar resumo do filme '{comentario_item.get('titulo_filme')}': {e}")
self.conn.rollback()
def formatar_estrelas(self, lista_estrelas_raw):
"""Converte as classes CSS de estrelas em descrições legíveis."""
formatadas = []
for estado in lista_estrelas_raw:
if '10' in estado:
formatadas.append('Uma Estrela')
elif '15' in estado:
formatadas.append('Uma Estrela e Meia')
elif '20' in estado:
formatadas.append('Duas Estrelas')
elif '25' in estado:
formatadas.append('Duas Estrelas e Meia')
elif '30' in estado:
formatadas.append('Três Estrelas')
elif '35' in estado:
formatadas.append('Três Estrelas e Meia')
elif '40' in estado:
formatadas.append('Quatro Estrelas')
elif '45' in estado:
formatadas.append('Quatro Estrelas e Meia')
elif '50' in estado:
formatadas.append('Cinco Estrelas')
else:
formatadas.append('Não Avaliado / Outro') # Padrão para casos não previstos
return formatadas
Middlewares (middlewares/)
Os middlewares permitem estender a funcionalidade do Scrapy, como rotação de User-Agents e proxies.
Rotação de Proxies (middlewares/proxy_rotativo.py)
Este middleware gerencia uma lista de proxies, alternando-os para cada requisição e removendo aqueles que falham consistentemente.
# !/usr/bin/env python
# coding=utf-8
import random
import requests
import json # Usar json para parsing seguro da resposta da API
import logging
from urllib.parse import urlparse
from scrapy.exceptions import NotConfigured
class MiddlewareProxyAleatorio:
"""Middleware para atribuir um proxy aleatório a cada requisição."""
max_falhas_proxy = 3 # Número máximo de falhas antes de remover um proxy
def __init__(self, settings):
self.proxies_disponiveis = {} # Dicionário para armazenar proxies e suas contagens de falhas
self.api_proxy_url = settings.get('PROXY_API_URL', 'http://sua_api_de_proxies/get_proxy') # URL da API de proxies
# Carrega proxies iniciais
if self.api_proxy_url:
self.recarregar_proxies()
else:
logging.warning("PROXY_API_URL não configurado. O middleware de proxy não será totalmente funcional.")
raise NotConfigured # Levanta NotConfigured se não houver URL de API
def recarregar_proxies(self):
"""Busca uma nova lista de proxies da API."""
logging.info("Recarregando proxies da API...")
try:
response = requests.get(self.api_proxy_url, timeout=10)
response.raise_for_status() # Lança HTTPError para respostas de erro (4xx ou 5xx)
data = json.loads(response.text) # Usa json.loads para segurança
if data.get('code') == '0':
novos_proxies = []
for item in data.get('msg', []):
ip = item.get('ip')
port = item.get('port')
if ip and port:
proxy_completo = f'http://{ip}:{port}'
novos_proxies.append(proxy_completo)
if novos_proxies:
# Reinicializa a lista de proxies disponíveis
self.proxies_disponiveis = {p: 0 for p in novos_proxies}
logging.info(f"Proxies recarregados: {len(self.proxies_disponiveis)} novos proxies.")
else:
logging.warning("API de proxies retornou sucesso, mas nenhuma lista de IPs válida.")
elif data.get('code') == '3006':
logging.warning(f"API de proxies retornou código 3006: {data.get('msg')}")
else:
logging.error(f"API de proxies retornou status inesperado: {data.get('code')} - {data.get('msg')}")
except requests.exceptions.RequestException as e:
logging.error(f"Erro ao conectar ou receber resposta da API de proxies: {e}")
except json.JSONDecodeError as e:
logging.error(f"Erro ao decodificar JSON da API de proxies: {e}")
except Exception as e:
logging.error(f"Erro inesperado ao recarregar proxies: {e}")
@classmethod
def from_crawler(cls, crawler):
"""Cria uma instância do middleware a partir das configurações do crawler."""
if not crawler.settings.getbool('HTTPPROXY_ENABLED'):
raise NotConfigured("Middleware de Proxy desabilitado nas configurações.")
return cls(crawler.settings)
def process_request(self, request, spider):
"""Atribui um proxy aleatório à requisição se não houver um já definido."""
if not self.proxies_disponiveis:
logging.warning("Lista de proxies vazia. Tentando recarregar...")
self.recarregar_proxies()
if not self.proxies_disponiveis:
logging.error("Nenhum proxy disponível após recarga. Requisição pode falhar.")
return # Permite que a requisição prossiga sem proxy
if 'proxy' not in request.meta:
proxy_selecionado = random.choice(list(self.proxies_disponiveis.keys()))
request.meta['proxy'] = proxy_selecionado
request.meta['proxy_stats_key'] = proxy_selecionado # Guarda a chave original para estatísticas
logging.debug(f"Atribuído proxy {proxy_selecionado} para {request.url}")
def process_response(self, request, response, spider):
"""Verifica a resposta e gerencia o status do proxy."""
proxy_usado = request.meta.get('proxy_stats_key')
if response.status in [301, 302, 307, 403, 404, 503]: # Códigos que podem indicar problemas no proxy
logging.warning(f"Proxy {proxy_usado} recebeu resposta de status {response.status} para {request.url}. Incrementando falhas.")
self._marcar_falha_proxy(proxy_usado)
# Reagenda a requisição com um novo proxy
nova_request = request.copy()
nova_request.meta['proxy'] = random.choice(list(self.proxies_disponiveis.keys())) if self.proxies_disponiveis else None
logging.info(f"Reagendando requisição para {nova_request.url} com novo proxy.")
return nova_request
return response
def process_exception(self, request, exception, spider):
"""Lida com exceções (erros de rede) e gerencia o status do proxy."""
proxy_usado = request.meta.get('proxy_stats_key')
if proxy_usado:
logging.error(f"Proxy {proxy_usado} causou exceção ({type(exception).__name__}) para {request.url}. Marcando como falha.")
self._marcar_falha_proxy(proxy_usado)
# Reagenda a requisição com um novo proxy
nova_request = request.copy()
nova_request.dont_filter = True # Garante que a requisição seja processada novamente
nova_request.meta['proxy'] = random.choice(list(self.proxies_disponiveis.keys())) if self.proxies_disponiveis else None
logging.info(f"Reagendando requisição para {nova_request.url} com novo proxy após exceção.")
return nova_request
return None # Deixa o Scrapy lidar com a exceção se não houver proxy
def _marcar_falha_proxy(self, proxy_addr):
"""Incrementa a contagem de falhas de um proxy e o remove se exceder o limite."""
if proxy_addr in self.proxies_disponiveis:
self.proxies_disponiveis[proxy_addr] += 1
if self.proxies_disponiveis[proxy_addr] >= self.max_falhas_proxy:
del self.proxies_disponiveis[proxy_addr]
logging.warning(f"Proxy {proxy_addr} removido da lista por excesso de falhas.")
if not self.proxies_disponiveis:
logging.warning("Lista de proxies vazia. Recarregando...")
self.recarregar_proxies()
if not self.proxies_disponiveis:
logging.critical("Nenhum proxy disponível após várias tentativas de recarga. A raspagem pode ser interrompida.")
Rotação de User-Agents (middlewares/useragent_rotativo.py)
Este middleware atribui um User-Agent aleatório a cada requisição, emulando diferentes navegadores e dispositivos para evitar bloqueios.
import faker
import logging
class MiddlewareUserAgentRotativo(object):
"""Atribui um User-Agent aleatório a cada requisição."""
def __init__(self, settings):
self.gerador_falso = faker.Faker()
logging.info("Middleware de User-Agent Rotativo inicializado.")
@classmethod
def from_crawler(cls, crawler):
"""Cria uma instância do middleware."""
return cls(crawler.settings)
def process_request(self, request, spider):
"""Define um User-Agent aleatório no cabeçalho da requisição."""
novo_user_agent = self.gerador_falso.user_agent()
request.headers['User-Agent'] = novo_user_agent
logging.debug(f"User-Agent atribuído: {novo_user_agent} para {request.url}")
def process_response(self, request, response, spider):
"""Processa a resposta (não faz alterações no User-Agent)."""
# logging.debug(f"Resposta recebida com User-Agent: {request.headers.get('User-Agent')} para {request.url}")
return response
Configurações do Projeto (settings.py)
As configurações controlam o comportamento geral do Scrapy, incluindo middlewares, pipelines e outros parâmetros.
# -*- coding: utf-8 -*-
# Scrapy settings for douban project
BOT_NAME = 'douban'
SPIDER_MODULES = ['douban.spiders']
NEWSPIDER_MODULE = 'douban.spiders'
# User-Agent padrão (será sobrescrito pelo middleware de rotação)
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
# Obedecer às regras do robots.txt (definido como False para Douban)
ROBOTSTXT_OBEY = False
# Habilitar o middleware de proxy
HTTPPROXY_ENABLED = True
PROXY_API_URL = 'http://sua_api_de_proxies/get_proxy' # **ATUALIZE ESTA URL COM SUA API DE PROXIES**
# Tempo limite para download de requisições
DOWNLOAD_TIMEOUT = 15
# Número máximo de requisições simultâneas
CONCURRENT_REQUESTS = 4
# Aumentar o tamanho do threadpool do Twisted Reactor (para evitar bloqueios com Selenium)
REACTOR_THREADPOOL_MAXSIZE = 30
# Configurações de log
LOG_ENABLED = True
LOG_ENCODING = 'utf-8'
LOG_FILE = f"./log/{time.strftime('%Y-%m-%d')}_douban_crawl.log"
LOG_LEVEL = 'INFO' # DEBUG, INFO, WARNING, ERROR, CRITICAL
# Desabilitar redirecionamentos (pode ser útil para anti-bot)
REDIRECT_ENABLED = False
# Configurações para middlewares de download
# Note: As classes Scrapy padrão devem usar 'scrapy.downloadermiddlewares'
DOWNLOADER_MIDDLEWARES = {
'douban.middlewares.useragent_rotativo.MiddlewareUserAgentRotativo': 543,
'douban.middlewares.proxy_rotativo.MiddlewareProxyAleatorio': 749,
# Scrapy padrão, se necessário reordenar ou desabilitar:
'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware': 100,
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware': 300,
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None, # Desabilita o padrão
'scrapy.downloadermiddlewares.retry.RetryMiddleware': 500,
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware': 550,
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': None, # Desabilita o padrão
'scrapy.downloadermiddlewares.cookies.CookiesMiddleware': 700,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None, # Desabilita o padrão
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 800,
'scrapy.downloadermiddlewares.stats.DownloaderStats': 850,
'scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware': 900,
}
# Atraso entre requisições para o mesmo domínio
DOWNLOAD_DELAY = 1.5 # Em segundos
# Desabilitar cookies para evitar rastreamento ou estados indesejados
COOKIES_ENABLED = False
# Desabilitar retries padrão do Scrapy, pois o middleware de proxy já gerencia
RETRY_ENABLED = False
# Cabeçalhos de requisição padrão (serão complementados pelo User-Agent do middleware)
DEFAULT_REQUEST_HEADERS = {
'Accept': "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
'Accept-Encoding': "gzip, deflate, br",
'Accept-Language': "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
'Cache-Control': "no-cache",
'Connection': "keep-alive",
'Host': "movie.douban.com", # Corrigido para movie.douban.com
'Pragma': "no-cache",
'Upgrade-Insecure-Requests': "1",
}
# Configuração dos Item Pipelines
ITEM_PIPELINES = {
'douban.pipelines.PipelineValidacaoDados': 300,
'douban.pipelines.PipelineRedis': 301,
'douban.pipelines.PipelineMySQL': 302,
}
# AutoThrottle (ajuste dinâmico do atraso de download)
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 2
AUTOTHROTTLE_MAX_DELAY = 60
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
AUTOTHROTTLE_DEBUG = False
# HTTP Cache (desabilitado por padrão)
# HTTPCACHE_ENABLED = True
# HTTPCACHE_EXPIRATION_SECS = 0
# HTTPCACHE_DIR = 'httpcache'
# HTTPCACHE_IGNORE_HTTP_CODES = []
# HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage'
Estrutura do Banco de Dados (MySQL)
Para o armazenamento dos dados, sugere-se a seguinte estrutura para as tabelas MySQL:
-- Tabela para detalhes completos de cada filme
CREATE TABLE `filmes_detalhes` (
`id_filme` VARCHAR(20) NOT NULL PRIMARY KEY,
`titulo` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`url_poster` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`url_detalhes` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`pontuacao_media` VARCHAR(10) NULL,
`duracao` VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`data_lancamento` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`diretor` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`atores_principais` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`pais_producao` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`roteirista` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`numero_avaliacoes` VARCHAR(50) NULL,
`sinopse` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`genero` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`idioma` VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`titulo_alternativo` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`classificacao_rank` VARCHAR(10) NULL,
`url_comentarios` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`data_raspagem` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Tabela para os comentários de cada filme
CREATE TABLE `filmes_comentarios` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`id_filme` VARCHAR(20) NOT NULL,
`titulo_filme` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`nome_usuario` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`estrelas_avaliacao` VARCHAR(50) NULL,
`texto_comentario` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`data_comentario` VARCHAR(50) NULL,
`url_comentarios` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`url_avatar_usuario` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`data_raspagem` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`id_filme`) REFERENCES `filmes_detalhes`(`id_filme`) ON DELETE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Tabela de resumo para a página inicial (se houver) ou para métricas agregadas
CREATE TABLE `filmes_resumo` (
`id_filme` VARCHAR(20) NOT NULL PRIMARY KEY,
`titulo_filme` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`taxa_positivo` VARCHAR(10) NULL,
`taxa_neutro` VARCHAR(10) NULL,
`taxa_negativo` VARCHAR(10) NULL,
`url_comentarios` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`url_poster_filme` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`url_detalhes_filme` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`data_raspagem` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`id_filme`) REFERENCES `filmes_detalhes`(`id_filme`) ON DELETE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Execução do Rastreador
Para iniciar o processo de raspagem, navegue até o diretório raiz do seu projeto Scrapy no terminal e execute o seguinte comando:
scrapy crawl aranha_filmes_douban
A saída do console e os logs (./log/\*.log) fornecerão informações sobre o progresso e quaisquer erros encontrados. Os dados serão persistidos no banco de dados MySQL e, se configurado, no Redis.