Ir para o conteúdo

Seletores

Quando você faz scraping de páginas web, o objetivo principal é obter informações úteis dessas páginas. Para isso, precisamos extrair esses dados do código HTML da página.

Em Python existem várias bibliotecas disponíveis para isso, como BeautifulSoup e lxml. No entanto, o Scrapy já possui seu próprio mecanismo para extrair dados, chamado de seletores. Eles recebem esse nome porque "selecionam" partes específicas do documento HTML, com base em expressões XPath ou CSS.

Ao utilizar os seletores CSS ou XPath, o spider irá identificar e extrair os dados desejados de cada página de produto, como SKU, nome, preço, descrição, URL e imagem. Essa personalização permite adaptar o processo de extração às nossas necessidades específicas, coletando apenas as informações relevantes para o projeto.

Consultar respostas das requisições usando XPath e CSS é tão comum que a response inclui mais dois atalhos: response.xpath() e response.css().

Considere o seguinte código HTML de uma página de exemplo:

Código da página https://docs.scrapy.org/en/latest/_static/selectors-sample1.html
<!DOCTYPE html>
<html>
  <head>
    <base href='http://example.com/' />
    <title>Example website</title>
  </head>
  <body>
    <div id='images'>
      <a href='image1.html'>Name: My image 1 <br /><img src='image1_thumb.jpg' alt='image1'/></a>
      <a href='image2.html'>Name: My image 2 <br /><img src='image2_thumb.jpg' alt='image2'/></a>
      <a href='image3.html'>Name: My image 3 <br /><img src='image3_thumb.jpg' alt='image3'/></a>
      <a href='image4.html'>Name: My image 4 <br /><img src='image4_thumb.jpg' alt='image4'/></a>
      <a href='image5.html'>Name: My image 5 <br /><img src='image5_thumb.jpg' alt='image5'/></a>
    </div>
  </body>
</html>

Agora vamos abrir o shell do Scrapy para explorar essa página:

$ Execução no terminal!
scrapy shell https://docs.scrapy.org/en/latest/_static/selectors-sample1.html

Após o shell ser carregado, você terá a resposta da requisição disponível na variável response. O seletor da página estará acessível através do atributo response.selector e você poderá utilizá-lo com expressões CSS ou XPath para extrair dados.

CSS

Os seletores CSS são baseados nas regras usadas para estilização de elementos em HTML.

title::text — Seleciona o texto dentro da tag <title>

>>> Execução no shell do Scrapy
response.css('title::text').get() # (1)!
  1. Seleciona o texto dentro da tag <title>
scrapy shell
'Example website'

#images a — Seleciona todos os links (tags <a>) dentro da tag <div> que possui o id images

>>> Execução no shell do Scrapy
response.css('#images a').getall()
scrapy shell
['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg" alt="image1"></a>', '<a href="image2.html">Name: My image 2 <br><img src="image2_thumb.jpg" alt="image2"></a>', '<a href="image3.html">Name: My image 3 <br><img src="image3_thumb.jpg" alt="image3"></a>', '<a href="image4.html">Name: My image 4 <br><img src="image4_thumb.jpg" alt="image4"></a>', '<a href="image5.html">Name: My image 5 <br><img src="image5_thumb.jpg" alt="image5"></a>']

#images img::attr(src) — Selecionar o atributo src da imagem

>>> Execução no shell do Scrapy
response.css('#images img::attr(src)').getall() # (1)!
  1. Extrai o valor do atributo src de cada imagem
scrapy shell
['image1_thumb.jpg', 'image2_thumb.jpg', 'image3_thumb.jpg', 'image4_thumb.jpg', 'image5_thumb.jpg']

#images a::attr(href) — Selecionar o atributo href

>>> Execução no shell do Scrapy
response.css('#images a::attr(href)').getall() # (1)!
  1. Extrai o valor do atributo href de cada link
scrapy shell
['image1.html', 'image2.html', 'image3.html', 'image4.html', 'image5.html']

XPath

Os seletores XPath são baseados em uma linguagem de consulta que permite navegar e selecionar partes de documentos XML e HTML. O XPath utiliza uma sintaxe específica para localizar nós no documento, permitindo selecionar elementos com base em suas propriedades, hierarquia, e posição na árvore do documento.

Iremos extrair os mesmo elementos anteriores só que agora usando seletores XPath.

//title/text() — Seleciona o texto dentro da tag <title>

>>> Execução no shell do Scrapy
response.xpath("//title/text()").get() # (1)!
  1. Seleciona o texto dentro da tag <title>
scrapy shell
'Example website'

//*[@id="images"]//a — Seleciona todos os links (tags <a>) dentro da tag <div> que possui o id images

>>> Execução no shell do Scrapy
response.xpath('//*[@id="images"]//a').getall()
scrapy shell
['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg" alt="image1"></a>', '<a href="image2.html">Name: My image 2 <br><img src="image2_thumb.jpg" alt="image2"></a>', '<a href="image3.html">Name: My image 3 <br><img src="image3_thumb.jpg" alt="image3"></a>', '<a href="image4.html">Name: My image 4 <br><img src="image4_thumb.jpg" alt="image4"></a>', '<a href="image5.html">Name: My image 5 <br><img src="image5_thumb.jpg" alt="image5"></a>']

//*[@id="images"]//img/@src — Selecionar o atributo src da imagem

>>> Execução no shell do Scrapy
response.xpath('//*[@id="images"]//img/@src').getall() # (1)!
  1. Extrai o valor do atributo src de cada imagem
scrapy shell
['image1_thumb.jpg', 'image2_thumb.jpg', 'image3_thumb.jpg', 'image4_thumb.jpg', 'image5_thumb.jpg']

//*[@id="images"]//a/@href — Selecionar o atributo href

>>> Execução no shell do Scrapy
response.xpath('//*[@id="images"]//a/@href').getall() # (1)!
  1. Extrai o valor do atributo href de cada link
scrapy shell
['image1.html', 'image2.html', 'image3.html', 'image4.html', 'image5.html']

No XPath, a função contains pode ser usada para verificar se um nó contém uma substring específica.

//title[contains(text(), "website")]/text() — Selecionar o texto da tag <title> se contém o texto "website"

>>> Execução no shell do Scrapy
response.xpath('//title[contains(text(), "website")]/text()').get() 
scrapy shell
'Example website'

No CSS, não existe uma função direta equivalente a contains, mas você pode usar seletores de atributos que verificam se um atributo contém uma substring.

a[href*="image"] — Selecionar as URLs (href) de todas as tags <a> cujo atributo href contém a palavra "image"

>>> Execução no shell do Scrapy
response.css('a[href*="image"]::attr(href)').getall()
scrapy shell
['image1.html', 'image2.html', 'image3.html', 'image4.html', 'image5.html']

É possível usar XPath com expressões regulares.

#images a::attr(href) — Selecionar o atributo href

>>> Execução no shell do Scrapy
response.xpath('//a[contains(@href, "image")]/text()').re(r"Name:\s*(.*)")
scrapy shell
['My image 1 ', 'My image 2 ', 'My image 3 ', 'My image 4 ', 'My image 5 ']

Agora que aprendemos um pouco sobre seletores do Scrapy, estamos prontos para extrair os dados da página da Shop Ecommerce!

Parseando conteúdo HTML

1. Análise a estrutura da página

Primeiro, vamos abrir a página no navegador. Clique com o botão direito do mouse em uma área de produto e selecione a opção Inspect. A seção DevTools será aberta:

2. Escolha de seletores

O selector precisa ser estratégico para poder encontrar os elementos HTML que contém as informações. Analisando a página da Shop Ecommerce notamos que:

li.product: Contém todos os elementos do produto

Para extrair os dados dos produtos temos:

  • URL: <a>
  • Imagem do produto: <img>
  • Nome do produto: <h2>
  • Preço do produto: <span> .product-price

Testando seletores

Vamos testar esses seletores antes de refatorar nosso método parse():

$ Execução no terminal!
scrapy shell https://www.scrapingcourse.com/ecommerce/

li.product — Seleciona o texto dentro da tag <title>

>>> Execução no shell do Scrapy
response.css('li.product')
  1. Seleciona o texto dentro da tag <title>
scrapy shell
[<Selector query="descendant-or-self::li[@class and contains(concat(' ', normalize-space(@class), ' '), ' product ')]" data='<li data-products="item" itemscope it...'>, <Selector query="descendant-or-self::li[@class and contains(concat(' ', normalize-space(@class), ' '), ' product ')]" data='<li data-products="item" itemscope it...'>, <Selector query="descendant-or-self::li[@class and contains(concat(' ', normalize-space(@class), ' '), ' product ')]" data='<li data-products="item" itemscope it...'>]

Vamos verificar se ele contém todos os nós do produto

>>> Execução no shell do Scrapy
len(response.css("li.product"))
scrapy shell
16

Agora vamos testar a estratégia de seletor em um único produto, o primeiro da lista de resultados.

>>> Execução no shell do Scrapy
product = response.css("li.product")[0]
product.css("img").attrib["src"]  
scrapy shell
'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/mh09-blue_main.jpg'

Agora é sua vez de brincar com os seletores e descobrir como extrair outros dados do produto como imagem, nome e preço.

Refatorando parse()

def parse(self, response):
    products = response.css(".product") # (1)!

    for product in products: # (2)!
        price_text_elements = product.css(".price *::text").getall() # (3)!
        price = "".join(price_text_elements) # (4)!
        url = product.css("a").attrib["href"] # (5)!
        image = product.css("img").attrib["src"] # (6)!
        name =  product.css("h2::text").get() # (7)!


        yield {  # (8)! 
            "url": url,
            "image": image,
            "name": name, 
            "price": price, 
        }
  1. Usa o método css() para selecionar todos os elementos que têm a classe .product
  2. Itera sobre cada produto encontrado para extrair suas informações específicas.
  3. Captura todos os textos de qualquer elemento dentro do bloco com a classe .price
  4. Concatena todos os elementos de texto do preço
  5. Extrai o valor do atributo href da tag <a>, que representa o link para o produto
  6. Extrai o valor do atributo src da tag <img>, que contém a URL da imagem do produto
  7. Extrai o texto do elemento

    , que provavelmente é o nome do produto

  8. A função parse é assíncrona retorna um gerador de objetos para os itens raspados (dados extraídos)

O método parse primeiro obtém todos os nós de produtos usando um selector css(). Em seguida, a função itera sobre os produtos e recupera as informações desejadas em cada um deles.

A função parse é assíncrona e deve retornar um gerador de objetos scrapy.Request ou scrapy.Item usando a instrução yield do Python. Nesse caso, ela retorna geradores para os itens raspados (dados extraídos).

Código completo da spider:

import scrapy


class ShopecommerceSpider(scrapy.Spider):
    name = "shopecommerce"
    allowed_domains = ["www.scrapingcourse.com"]
    start_urls = ["https://www.scrapingcourse.com/ecommerce/"]

    def parse(self, response):
        products = response.css(".product")

        for product in products:
            price_text_elements = product.css(".price *::text").getall()
            price = "".join(price_text_elements)

            yield {
                "url": product.css("a").attrib["href"],
                "image": product.css("img").attrib["src"],
                "name": product.css("h2::text").get(),  
                "price": price,
            }

Vamos executar a spider que acabamos de criar e analisar a saída no terminal.

$ Execução no terminal!
scrapy crawl shopecommerce  
scrapy shell
# outros logs
{'url': 'https://www.scrapingcourse.com/ecommerce/product/apollo-running-short/', 'image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/msh02-black_main.jpg', 'name': 'Apollo Running Short', 'price': '$32.50'}
2024-10-15 20:03:25 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/>
{'url': 'https://www.scrapingcourse.com/ecommerce/product/arcadio-gym-short/', 'image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/msh11-blue_main.jpg', 'name': 'Arcadio Gym Short', 'price': '$20.00'}
2024-10-15 20:03:25 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/>
{'url': 'https://www.scrapingcourse.com/ecommerce/product/argus-all-weather-tank/', 'image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/mt07-gray_main.jpg', 'name': 'Argus All-Weather Tank', 'price': '$22.00'}
# outros produtos
2024-10-15 20:03:25 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/>
{'url': 'https://www.scrapingcourse.com/ecommerce/product/ariel-roll-sleeve-sweatshirt/', 'image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/wh09-purple_main.jpg', 'name': 'Ariel Roll Sleeve Sweatshirt', 'price': '$39.00'}
2024-10-15 20:03:25 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/>
{'url': 'https://www.scrapingcourse.com/ecommerce/product/artemis-running-short/', 'image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/wsh04-black_main.jpg', 'name': 'Artemis Running Short', 'price': '$45.00'}
2024-10-15 20:03:25 [scrapy.core.engine] INFO: Closing spider (finished)
2024-10-15 20:03:25 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 276,
'downloader/request_count': 1,
'downloader/request_method_count/GET': 1,
'downloader/response_bytes': 14857,
'downloader/response_count': 1,
'downloader/response_status_count/200': 1,
'elapsed_time_seconds': 1.066071,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2024, 10, 15, 23, 3, 25, 748575, tzinfo=datetime.timezone.utc),
'httpcompression/response_bytes': 82409,
'httpcompression/response_count': 1,
'item_scraped_count': 16,
'log_count/DEBUG': 20,
'log_count/INFO': 10,
'memusage/max': 69353472,
'memusage/startup': 69353472,
'response_received_count': 1,
'scheduler/dequeued': 1,
'scheduler/dequeued/memory': 1,
'scheduler/enqueued': 1,
'scheduler/enqueued/memory': 1,
'start_time': datetime.datetime(2024, 10, 15, 23, 3, 24, 682504, tzinfo=datetime.timezone.utc)}
2024-10-15 20:03:25 [scrapy.core.engine] INFO: Spider closed (finished)

Uuhuu! Parabéns! Você acaba de extrair dados dos produtos Shop Ecommerce e temos um novo desafio para você 🚀

Note que a página (https://www.scrapingcourse.com/ecommerce/) exibe diversos produtos e contém uma paginação. Até o momento, nossa spider está capturando apenas os 16 produtos da primeira página 🧐

Vamos aprimorar a lógica de extração para que a spider siga as URLs de paginação e colete os produtos de todas as páginas disponíveis, garantindo uma raspagem completa!

Seguindo as páginas dos produtos

O objeto scrapy.Request é uma parte central do Scrapy, usado para gerar novas requisições HTTP que serão processadas pela spider. Ele permite que a spider continue navegando para outras páginas e colete mais dados de forma eficiente.

Código para acessar URLs da paginação
next_page = response.css('a.page-numbers.next::attr(href)').get() # (1)!
if next_page: # (2)!
    yield scrapy.Request( # (3)!
        url=next_page, callback=self.parse
    )
  1. Utiliza um seletor CSS para localizar o link da próxima página de produtos
  2. Verifica se a URL da próxima página foi encontrada.
  3. Se o link da próxima página existir, faz uma nova requisição para essa URL e continua a chamada da função parse, permitindo a captura dos produtos da página seguinte.

Código completo da spider:

import scrapy


class ShopecommerceSpider(scrapy.Spider):
    name = "shopecommerce"
    allowed_domains = ["www.scrapingcourse.com"]
    start_urls = ["https://www.scrapingcourse.com/ecommerce/"]

    def parse(self, response):
        products = response.css(".product")

        for product in products:
            price_text_elements = product.css(".price *::text").getall()
            price = "".join(price_text_elements)
            yield {
                "url": product.css("a").attrib["href"],
                "image": product.css("img").attrib["src"],
                "name": product.css("h2::text").get(),  
                "price": price,
            }

        next_page = response.css('a.page-numbers.next::attr(href)').get()
        if next_page:
            yield scrapy.Request(
                url=next_page, callback=self.parse
            )

Vamos executar a spider e analisar o log de execução:

$ Execução no terminal!
scrapy crawl shopecommerce  
scrapy shell
# ... outros logs
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/viktor-lumatech-pant/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/mp02-gray_main.jpg', 'Name': 'Viktor LumaTech™ Pant', 'Price': '$46.00'}
2024-10-15 19:41:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/page/12/>
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/voyage-yoga-bag/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/wb01-black-0.jpg', 'Name': 'Voyage Yoga Bag', 'Price': '$32.00'}
2024-10-15 19:41:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/page/12/>
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/vulcan-weightlifting-tank/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/mt06-black_main.jpg', 'Name': 'Vulcan Weightlifting Tank', 'Price': '$28.00'}
2024-10-15 19:41:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/page/12/>
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/wayfarer-messenger-bag/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/mb05-black-0.jpg', 'Name': 'Wayfarer Messenger Bag', 'Price': '$45.00'}
2024-10-15 19:41:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/page/12/>
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/zeppelin-yoga-pant/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/mp08-green_main.jpg', 'Name': 'Zeppelin Yoga Pant', 'Price': '$82.00'}
2024-10-15 19:41:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/page/12/>
# ... os outros produtos
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/zing-jump-rope/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/ug04-bk-0.jpg', 'Name': 'Zing Jump Rope', 'Price': '$12.00'}
2024-10-15 19:41:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/page/12/>
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/zoe-tank/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/wt02-orange_main.jpg', 'Name': 'Zoe Tank', 'Price': '$29.00'}
2024-10-15 19:41:04 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.scrapingcourse.com/ecommerce/page/12/>
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/zoltan-gym-tee/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/ms06-blue_main.jpg', 'Name': 'Zoltan Gym Tee', 'Price': '$29.00'}
{'Url': 'https://www.scrapingcourse.com/ecommerce/product/zoltan-gym-tee/', 'Image': 'https://www.scrapingcourse.com/ecommerce/wp-content/uploads/2024/03/ms06-blue_main.jpg', 'Name': 'Zoltan Gym Tee', 'Price': '$29.00'}
2024-10-15 19:41:04 [scrapy.core.engine] INFO: Closing spider (finished)
2024-10-15 19:41:04 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 7094,
'downloader/request_count': 12,
'downloader/request_method_count/GET': 12,
'downloader/response_bytes': 179122,
'downloader/response_count': 12,
'downloader/response_status_count/200': 12,
'elapsed_time_seconds': 36.031586,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2024, 10, 15, 22, 41, 4, 437227, tzinfo=datetime.timezone.utc),
'httpcompression/response_bytes': 996668,
'httpcompression/response_count': 12,
'item_scraped_count': 188,
'log_count/DEBUG': 203,
'log_count/INFO': 10,
'memusage/max': 69779456,
'memusage/startup': 69779456,
'request_depth_max': 11,
'response_received_count': 12,
'scheduler/dequeued': 12,
'scheduler/dequeued/memory': 12,
'scheduler/enqueued': 12,
'scheduler/enqueued/memory': 12,
'start_time': datetime.datetime(2024, 10, 15, 22, 40, 28, 405641, tzinfo=datetime.timezone.utc)}
2024-10-15 19:41:04 [scrapy.core.engine] INFO: Spider closed (finished)

Pronto! Agora estamos coletando os produtos de todas as páginas e pelos logs podemos ver que temos 188 produtos.

Extraindo mais dados sobre os produtos

A coleta de informações básicas sobre os produtos na página inicial é apenas o primeiro passo. Para obter uma compreensão mais completa e útil dos itens disponíveis, é essencial extrair os dados adicionais nas páginas de detalhes dos produtos. Esses dados podem incluir informações como categoria, descrição, tamanho, cor e outros atributos específicos que podem influenciar a decisão de compra.

Vamos criar um método para fazer o parse da página de detalhe dos produtos. Lembre-se de antes testar os seletores no scrapy shell, assim você consegue explorar o HTML da página e validar se está selecionando corretamente o dado.

Para isso, escolha um dos produtos, analise sua página e experimente. Os seletores que você construir para essa página servirá para a dos outros produtos.

Exemplo:

$ Execução no terminal!
scrapy shell https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/

Implemente seletores para capturar até os dados básicos do produto que antes eram coletados pela página inicial. Assim iremos garantir que os dados sejam centralizados e capturados em um único lugar, simplificando o processo e evitando redundância.

Portanto, nosso desafio agora é coletar os dados de sku, nome do produto, categoria, imagem, preço, descrição, tamanho e cor na página de detalhes do produto.

Fica a seu critério escolher se vai preferir fazer com seletores CSS ou XPath.

Uma versão do método parse_detail() usando seletores CSS:

    def parse_detail(self, response):

        sku = response.css('div.summary div.product_meta span span::text').get()
        category = response.css('div.summary div.product_meta span a::text').get()
        name = response.css('h1.product_title::text').get()
        image = response.xpath('//div[contains(@class, "woocommerce-product-gallery")]//img/@src').get()
        price = response.css('div.summary p.price span.woocommerce-Price-amount bdi::text').get()
        description = response.css('#tab-description p::text').extract_first()
        size = response.css('tr.woocommerce-product-attributes-item--attribute_size td.woocommerce-product-attributes-item__value p::text').get()
        color = response.css('tr.woocommerce-product-attributes-item--attribute_color td.woocommerce-product-attributes-item__value p::text').get()

        yield {
            'sku': sku,
            'category': category,
            'name': name,
            'image': image,
            'price': price,
            'url': response.url,
            'description': description,
            'size': size,
            'color': color,
        }

Em seguida vamos refatorar o método parse() para a spider seguir os links de cada produto e coletar as informações de suas respectivas páginas. Ao navegar por essas páginas, as informações serão extraídas e organizadas em um dicionário.

    def parse(self, response):

        products = response.css('li.product')

        for product in products:
            url = product.css('a').attrib['href']
            yield scrapy.Request(url=url, callback=self.parse_detail)

        next_page = response.css('a.page-numbers.next').attrib['href']
        if next_page:
            yield scrapy.Request(
                url=next_page, callback=self.parse
            )

Código completo da spider:

    def parse(self, response):

        products = response.css('li.product')

        for product in products:
            url = product.css('a').attrib['href']
            yield scrapy.Request(url=url, callback=self.parse_detail)

        next_page = response.css('a.page-numbers.next').attrib['href']
        if next_page:
            yield scrapy.Request(
                url=next_page, callback=self.parse
            )