Implementando um Rastreador Híbrido Scrapy-Selenium para o Douban Top 250

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.

Tags: Scrapy Selenium Python web scraping Douban

Publicado em 7-18 16:40