Back to Blog

Web Scraping Ruby on Rails: Complete Guide with Code

Marcus Bennett

Aug 25, 2026 · Guides · 22 min read

TL;DR

Web scraping Ruby on Rails shouldn’t send requests directly from the Controller. The correct layering is: a Service Object handles fetching and parsing, Active Job handles asynchronous execution and limited retries, Active Record handles unique keys, idempotent writes, and history fields, and the Controller only creates the task or displays the result. For static HTML, prefer Net::HTTP/Faraday + Nokogiri; for dynamic pages, check for an authorized JSON/XHR endpoint first, then consider Ferrum, Selenium, or Capybara.

Page or Task First-Choice Tool Where It Lives in Rails
Static HTML, known URLs Net::HTTP/Faraday + Nokogiri app/services
Forms, cookies, ordinary sessions Mechanize Service + Job
JavaScript rendering Ferrum or Selenium/Capybara A dedicated browser worker
Bulk, scheduled collection Active Job + Solid Queue/Sidekiq app/jobs
Idempotent writes and history Active Record + a unique index Model / migration
Authorized region targeting or a fixed exit Rola IP Network client configuration layer

What Is Web Scraping Ruby on Rails?

Web scraping with Ruby on Rails is the complete workflow — fetching pages, structured parsing, background scheduling, database persistence, and result display — carried out inside a Rails application. Ruby provides HTTP, HTML parsing, and browser automation capability; Rails adds Active Job, Active Record, credential management, logging, monitoring, and an admin interface.

If you only need to scrape a single page and export a CSV once, a plain Ruby script is usually lighter. Rails delivers real engineering value when the task needs to run on a schedule, maintain state, deduplicate, record price history, or be viewed by a back-office team or called by other parts of the business.

What’s the Difference Between Ruby Scraping and Rails Scraping?

Dimension Plain Ruby Script Ruby on Rails Application
Scope One-off, a small number of URLs, run from the command line Ongoing tasks, a database, an admin backend, team collaboration
Code entry point scraper.rb Service Object + Active Job
Data storage CSV/JSON Active Record + CSV/object storage
Scheduling cron or manual Solid Queue, Sidekiq, or a scheduler
Failure recovery Maintain state yourself Job retries, status fields, unique keys, and logging
Deployment cost Low Higher, but easier to maintain long-term

What’s the Complete Workflow for Ruby on Rails Web Scraping?

The full workflow is to define the data contract first, then identify the data source, and only then choose the tool. Follow this order; if any step can’t be verified, don’t let the task continue writing to the production database.

  1. Define the field contract: decide on the URL, SKU, name, price, currency, availability, region, fetch time, and which fields may be null.
  2. Check the access boundary: confirm authorization, terms of service, robots.txt, login requirements, request frequency, and data-retention rules.
  3. Identify the data source: check raw HTML, JSON-LD, embedded page JSON, and Fetch/XHR in that order, and only consider the final browser DOM last.
  4. Choose the fetching tool: use Net::HTTP/Faraday for static pages; use Mechanize for an ordinary form session; use Ferrum or Selenium when JavaScript must execute.
  5. Parse and validate: use Nokogiri’s CSS/XPath to parse, and validate the root node, field types, currency, region, and template version.
  6. Persist and schedule: the Service Object returns structured data, Active Record writes it idempotently, and Active Job controls the queue, retries, and timeouts.
  7. Monitor and accept: track network success rate and valid-data rate separately, and keep redacted response samples for regression testing.

Key judgment | Decide where the data lives before deciding which gem to use. Launching a browser right away often hides the real data source, adding resource consumption, wait conditions, and deployment failure points.

Whether it’s compliant depends on authorization, the site’s terms, the type of data, request frequency, and applicable law — not on whether you use Ruby or Rails. Being publicly visible doesn’t mean you can freely collect or republish it. robots.txt is a technical declaration, not full legal permission.

  • Prefer a site’s public API, data export, or an explicitly authorized page.
  • Don’t bypass logins, paywalls, CAPTCHAs, or other access controls; pause the task when you receive an explicit denial.
  • Limit concurrency per domain, support Retry-After, caching, and incremental updates to reduce repeated load.
  • Avoid collecting personal data unrelated to the business purpose; keep records of source, fetch time, and a deletion process.
  • Seek professional legal advice when personal data, copyrighted content, or large-scale republishing is involved.

How Do You Choose a Ruby Web Scraping Tool?

Need Recommended Gem/Component Deciding Factor
HTTP requests and middleware Faraday Need timeouts, retries, adapters, and logging
Minimal dependencies Net::HTTP Ruby’s standard library — fits controlled requests
HTML/XML parsing Nokogiri CSS/XPath, fault-tolerant parsing, and mature performance
Forms and cookies Mechanize Doesn’t execute JavaScript, but maintains an ordinary session
Chrome DevTools Ferrum Need JS rendering and want something lighter
Browser workflow Selenium + Capybara Need clicks, explicit waits, screenshots, or a test DSL
Task queue Active Job + Solid Queue/Sidekiq Async execution, retries, queue isolation

How Should You Choose Between Faraday, Net::HTTP, and HTTParty?

Rails projects usually default to Faraday, since it organizes timeouts, retries, logging, proxies, and adapters into a testable connection object. Net::HTTP suits simple, minimal-dependency requests. HTTParty has concise syntax, but complex middleware and multi-backend adapter needs are better served by Faraday. All three should still explicitly set timeouts and check status codes.

HTTP Client Strengths Limitations Best Fit
Net::HTTP Ruby standard library, no extra gem Lower-level interface; retries and middleware need to be built yourself Simple, controlled, dependency-sensitive requests
Faraday Clear middleware, adapters, logging, and test interfaces Requires an extra gem; more configuration A long-term-maintained Rails Service
HTTParty Declarative API, quick to pick up Less scalable than Faraday for complex connection strategies Small API/HTML clients
# Gemfile
gem "faraday"
gem "faraday-retry"

connection = Faraday.new(url: base_url) do |f|
  f.request :retry,
    max: 2,
    interval: 0.5,
    retry_statuses: [429, 500, 502, 503, 504]
  f.options.open_timeout = 5
  f.options.timeout = 20
end

response = connection.get(path)
raise "HTTP #{response.status}" unless response.success?
html = response.body

Retries should only apply to connection failures, timeouts, and clearly temporary responses. A 401, 403, a CAPTCHA, a broken selector, or a failed field validation needs to be handled separately — don’t put those into an undifferentiated retry loop.

Faraday’s connection, middleware, and adapter usage are covered in the official Faraday documentation.

What’s the Difference Between CSS Selectors and XPath in Nokogiri?

CSS selectors are closer to front-end styling syntax, and fit HTML with clear class names, attributes, and hierarchy. XPath is better suited to locating content by text, ancestor/sibling relationships, or complex structure. Stability depends on whether the selector expresses business meaning — not on the syntax itself.

Need CSS Example XPath Example
Product root node main.product[data-sku] //main[contains(@class,"product")][@data-sku]
Price attribute .price[data-currency] //*[contains(@class,"price")][@data-currency]
Locate by label text Usually needs further filtering in Ruby //dt[normalize-space()="SKU"]/following-sibling::dd[1]
doc = Nokogiri::HTML5(html)

doc.css("main.product").each do |root|
  name = root.at_css("h1.product-title")&.text&.strip
  price = root.at_xpath('.//*[contains(@class,"price")]')&.text&.strip
  raise "missing required product fields" if name.to_s.empty? || price.to_s.empty?
end

Avoid relying on auto-generated long class names, element indexes, or full DOM paths. Prefer data-*, itemprop, stable IDs, and business-field combinations, and keep a fixture test for each page template.

When Should You Scrape JSON, JSON-LD, or XHR Instead of HTML?

When an authorized structured response already contains the fields you need, parsing JSON is usually more stable and resource-efficient than reading the browser DOM. Check in this order: application/ld+json in the page, then an embedded state object, then Fetch/XHR in DevTools Network — don’t guess at undocumented endpoints or bypass authentication.

require "json"
require "nokogiri"

doc = Nokogiri::HTML5(html)
products = doc.css('script[type="application/ld+json"]').filter_map do |node|
  data = JSON.parse(node.text)
  items = data.is_a?(Array) ? data : [data]
  items.find { |item| item["@type"] == "Product" }
rescue JSON::ParserError
  nil
end

product = products.first
offer = product&.fetch("offers", nil)
price = offer.is_a?(Hash) ? offer["price"] : nil
  • Cross-check against the price visible on the page to confirm the JSON isn’t a stale cache, a default region, or a tax-exclusive price.
  • Log the Content-Type, schema version, and field path; an API returning HTTP 200 can still carry a business-level error.
  • Prefer a site’s public API; if an endpoint requires a token, login, or signature, only call it within an explicitly authorized scope.

How Do You Use Mechanize for Forms, Cookies, and Ordinary Sessions?

When a page depends on ordinary HTML forms, cookies, and redirects but doesn’t need JavaScript, Mechanize is usually lighter than a full browser. It can maintain a cookie jar, submit forms, and follow links. If a CAPTCHA, complex front-end components, or WebAuthn are part of the flow, don’t treat Mechanize as a browser substitute.

# Gemfile
gem "mechanize"

agent = Mechanize.new
agent.user_agent_alias = "Mac Safari"
agent.open_timeout = 5
agent.read_timeout = 20

page = agent.get(authorized_search_url)
form = page.form_with(action: /search/)
raise "search form not found" unless form

form.field_with(name: "q").value = "mechanical keyboard"
results_page = form.submit
rows = results_page.search(".result-card").map do |card|
  { name: card.at_css(".title")&.text&.strip,
    url: card.at_css("a")&.[]("href") }
end

Session boundary | Don’t use form automation to bypass a login, a CAPTCHA, or access control. Treat account sessions, cookies, and personal data as sensitive credentials, and log only the necessary redacted diagnostic information.

Forms, cookie jar, and page-navigation interfaces are covered in the official Mechanize documentation.

Environment and Dependency Versions

This article’s static scraping example was actually run on macOS 14.6 (Apple Silicon) with Ruby 2.6.10 and Nokogiri 1.13.8. For a production project, use a still-supported Ruby 3.2+ and a current Rails version, and pin dependencies according to your project’s lockfile.

Hands-On 1: Scrape a Static Product Page with Nokogiri

This exercise uses a local product test page to avoid depending on a third-party site that could change. The page contains 2 product cards, each with a SKU, name, price, currency, and availability. The test server only listens on 127.0.0.1.

gem install nokogiri
cd ruby_rails_scraping_test/public
python3 -m http.server 8877 --bind 127.0.0.1

local-static-product-test-page

Step 1: Build a Reusable ProductScraper

The Service Object does exactly one thing: turn a URL into a validated array of ProductRow. It explicitly sets connect and read timeouts, checks the HTTP status, parses CSS selectors with Nokogiri, and raises an error when there are no valid products. Monetary values use BigDecimal to avoid Float precision issues.

# frozen_string_literal: true

require "bigdecimal"
require "csv"
require "net/http"
require "nokogiri"
require "uri"

ProductRow = Struct.new(
  :source_url, :sku, :name, :price, :currency, :availability,
  keyword_init: true
)

class ProductScraper
  USER_AGENT = "NorthstarResearch/1.0 (+authorized-local-test)"

  def initialize(open_timeout: 5, read_timeout: 15)
    @open_timeout = open_timeout
    @read_timeout = read_timeout
  end

  def call(url)
    uri = URI.parse(url)
    response = fetch(uri)
    raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)

    parse(response.body, uri.to_s)
  end

  private

  def fetch(uri)
    request = Net::HTTP::Get.new(uri)
    request["User-Agent"] = USER_AGENT
    request["Accept"] = "text/html,application/xhtml+xml"

    Net::HTTP.start(
      uri.host,
      uri.port,
      use_ssl: uri.scheme == "https",
      open_timeout: @open_timeout,
      read_timeout: @read_timeout
    ) { |http| http.request(request) }
  end

  def parse(html, source_url)
    doc = Nokogiri::HTML5(html)
    rows = doc.css("article.product").map do |node|
      price_node = node.at_css(".price")
      ProductRow.new(
        source_url: source_url,
        sku: node["data-sku"],
        name: text(node, ".name"),
        price: decimal(text(node, ".price")),
        currency: price_node&.[]("data-currency"),
        availability: text(node, ".stock")
      )
    end
    raise "No valid product records found" if rows.empty?

    rows
  end

  def text(node, selector)
    node.at_css(selector)&.text&.strip
  end

  def decimal(value)
    normalized = value.to_s.gsub(/[^0-9.\-]/, "")
    normalized.empty? ? nil : BigDecimal(normalized)
  end
end

if $PROGRAM_NAME == __FILE__
  url = ARGV.fetch(0, "http://127.0.0.1:8877/products.html")
  rows = ProductScraper.new.call(url)
  CSV.open("products.csv", "w", write_headers: true,
    headers: ProductRow.members) do |csv|
    rows.each do |row|
      values = row.to_h.merge(price: row.price&.to_s("F"))
      csv << ProductRow.members.map { |field| values[field] }
    end
  end
  rows.each { |row| puts "OK #{row.sku} | #{row.name} | #{row.currency} #{row.price.to_s('F')} | #{row.availability}" }
  puts "WROTE #{rows.length} records to products.csv"
end

Net::HTTP’s request, timeout, and proxy classes are covered in the official Ruby Net::HTTP documentation; CSS/XPath parsing is covered in the official Nokogiri tutorial.

Step 2: Run the Script and Check the Fields

ruby -c product_scraper.rb
ruby product_scraper.rb http://127.0.0.1:8877/products.html

ruby-nokogiri-script-terminal-output

Step 3: Export CSV Without Losing Data Types

CSV suits sample checks and one-off deliverables, but when writing to a database you should keep Decimal, timestamp, and unique-key types intact. Explicitly convert BigDecimal to a plain decimal string before writing CSV; raise an error when rows is empty, to avoid producing a “successful” file that only has headers.

products-csv-output

Hands-On 2: Plug the Scraping Logic into a Rails Service Object

In Rails, don’t scrape a page directly inside a Controller, a Model callback, or a View. The Controller can call ScrapeProductCatalogJob.perform_later(url), while the actual networking and parsing logic lives in the Service Object. This lets you test the parser in isolation, and reuse the same service from the command line, a Job, and the admin backend.

# app/services/product_catalog_sync.rb
class ProductCatalogSync
  def initialize(scraper: ProductScraper.new)
    @scraper = scraper
  end

  def call(url)
    now = Time.current
    rows = @scraper.call(url).map do |row|
      row.to_h.merge(
        price: row.price&.to_s("F"),
        scraped_at: now, created_at: now, updated_at: now
      )
    end

    Product.upsert_all(
      rows,
      unique_by: :index_products_on_source_url_and_sku
    )
  end
end

How Do You Save Scraping Results Idempotently with Active Record?

The key to idempotent writes is a database unique index — not a select-then-insert pattern. After creating a unique index on source_url + sku, upsert_all can update the same product on a repeated scrape, avoiding duplicate rows from concurrent Jobs. Price history should live in a separate table — don’t overwrite and lose the change trail.

class CreateProducts < ActiveRecord::Migration[7.1]
  def change
    create_table :products do |t|
      t.string :source_url, null: false
      t.string :sku, null: false
      t.string :name
      t.decimal :price, precision: 12, scale: 2
      t.string :currency, limit: 3
      t.string :availability
      t.datetime :scraped_at, null: false
      t.timestamps
    end

    add_index :products, [:source_url, :sku],
      unique: true,
      name: :index_products_on_source_url_and_sku
  end
end

The parameters and behavior of bulk writes are covered in the Rails upsert_all API.

How Do You Schedule and Retry Scraping Jobs with Active Job?

The Job is responsible for “when to run and what to do on failure” — not for CSS selectors. Only retry connection timeouts, read timeouts, and clearly temporary errors; a broken parser, a 401/403, or an authorization problem shouldn’t be retried indefinitely. Different target domains should use separate queues or rate limiters.

class ScrapeProductCatalogJob < ApplicationJob
  queue_as :scraping

  retry_on Net::OpenTimeout, Net::ReadTimeout,
    wait: :polynomially_longer, attempts: 3

  def perform(url)
    ProductCatalogSync.new.call(url)
  end
end

# Enqueue it, instead of scraping synchronously inside a web request
ScrapeProductCatalogJob.perform_later(catalog_url)

Active Job’s queues, retries, and error handling are covered in the Rails Active Job Guide.

How Do You Scrape JavaScript-Rendered Pages?

First search View Source for the target field, then check DevTools Network’s Fetch/XHR. Nokogiri can only parse the HTML it receives — it doesn’t execute JavaScript. If an authorized JSON response already contains the data, requesting it directly is usually more stable. Only launch a browser when the target field genuinely requires JavaScript, scrolling, or a click to appear.

Hands-On 3: Scrape Delayed-Rendering Products with Ruby + Headless Chrome

The test page initially only returns Loading products...; JavaScript inserts two article.product elements 700 ms later. The script below launches local Chrome from Ruby, gives the page 2.5 seconds of virtual time, reads the DOM after execution, and hands it to Nokogiri for parsing. It doesn’t depend on Ferrum, and clearly demonstrates the difference between the raw HTML and the final DOM.

# frozen_string_literal: true

require "nokogiri"
require "open3"

url = ARGV.fetch(0, "http://127.0.0.1:8877/dynamic-products.html")
chrome = ENV.fetch(
  "CHROME_BIN",
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
)

stdout, stderr, status = Open3.capture3(
  chrome,
  "--headless=new",
  "--disable-gpu",
  "--virtual-time-budget=2500",
  "--dump-dom",
  url
)
raise "Chrome failed: #{stderr.lines.last}" unless status.success?

doc = Nokogiri::HTML5(stdout)
rows = doc.css("article.product").map do |node|
  {
    sku: node["data-sku"],
    name: node.at_css(".name")&.text&.strip,
    price: node.at_css(".price")&.text&.strip
  }
end
raise "JavaScript content did not render" if rows.empty?

rows.each { |row| puts "JS_OK #{row[:sku]} | #{row[:name]} | #{row[:price]}" }
puts "RENDERED #{rows.length} products"
ruby -c scrape_js_with_chrome.rb
ruby scrape_js_with_chrome.rb http://127.0.0.1:8877/dynamic-products.html

headless-chrome-dynamic-scrape-result

How Do You Wrap Dynamic Scraping into a Rails Ferrum Service?

A production Rails project can use Ferrum to control the Chrome DevTools Protocol directly. The Service should wait for a specific element, cap the per-page timeout, return a plain Hash, and always close the browser in ensure. Browser tasks need a dedicated queue — they shouldn’t share the same high-concurrency configuration as lightweight HTTP Jobs.

# Gemfile (recommended: pin versions in a supported Ruby 3.2+ project)
gem "ferrum"

class DynamicProductScraper
  def call(url)
    browser = Ferrum::Browser.new(
      timeout: 15,
      process_timeout: 10,
      browser_options: { "no-sandbox": nil }
    )
    browser.go_to(url)
    browser.at_css("article.product", wait: 10)

    rows = browser.css("article.product").map do |node|
      {
        sku: node.attribute("data-sku"),
        name: node.at_css(".name")&.text,
        price_text: node.at_css(".price")&.text
      }
    end
    raise "dynamic product list is empty" if rows.empty?
    rows
  ensure
    browser&.quit
  end
end

How Should You Choose Between Ferrum, Selenium, Capybara, and Watir?

Ferrum uses the Chrome DevTools Protocol directly, and fits lightweight Chrome automation in a Ruby project. Selenium suits multi-browser support and a mature WebDriver ecosystem. Capybara is a workflow DSL that typically runs on top of a driver like Selenium. Watir provides a more Ruby-like interface for browser element interaction. Don’t default to a browser just because a tool has more features.

Tool Core Positioning Strengths Main Cost
Ferrum A Chrome DevTools client Lighter, direct control over the network and page Chrome/Chromium-centric
Selenium Cross-browser WebDriver Mature ecosystem, explicit waits, broad compatibility Heavier driver, resource, and deployment footprint
Capybara A browser workflow DSL Clear selector and interaction semantics, swappable drivers Not an independent browser engine
Watir A Ruby-style browser API Intuitive element location and interaction Still depends on a browser/WebDriver underneath
  • Only need HTML: go back to Faraday/Net::HTTP + Nokogiri.
  • Need a Chrome worker plus network inspection: evaluate Ferrum first.
  • Need Chrome, Firefox, Edge, or reuse of an existing test infrastructure: evaluate Selenium.
  • The flow involves search, clicks, popups, and screenshots, and you want a test-style DSL: Capybara is clearer.
  • Wait for a specific state rather than a fixed sleep; on failure, save the URL, page type, redacted HTML, and a screenshot.

What’s the Difference Between Scraping One Page and Crawling Multiple Pages?

A scraper extracts data from one known page; a crawler discovers the next batch of URLs from a page and decides which URLs should enter the queue. When you already know all the URLs, batch-scraping directly is enough; only when URLs need to be discovered incrementally — through pagination, category links, or a cursor — do you need a crawler frontier, URL normalization, and persistent deduplication.

Page Situation URL Source Stop Condition Core Dedup Key
A known list of product URLs Database/CSV/API The queue is empty or the deadline is reached Canonical URL + business SKU
Traditional page numbers/Next a[rel=next] or page-number links No next, or max_pages reached Seen page URL + SKU
Cursor-based API next_cursor in the response Cursor is empty/repeats Cursor + business ID
Infinite scroll Scroll triggers DOM/XHR N consecutive rounds with no new items, or an end flag Business ID, not just the DOM count
Discovering in-site links Page href Queue empty, path rules, or budget reached Canonical URL

Hands-On 4: Crawl Traditional Pagination with Ruby and Deduplicate Across Pages

The local test directory contains 3 catalog pages. Page 1’s links carry a ref and a fragment; page 2’s carry a utm_source; product PG-002 appears again, duplicated across the first two pages. The full script normalizes URLs, restricts to the same host, follows rel=next, sets MAX_PAGES, and separately maintains seen_urls and seen_skus.

# frozen_string_literal: true

require "net/http"
require "nokogiri"
require "set"
require "uri"

START_URL = ARGV.fetch(0, "http://127.0.0.1:8877/catalog-1.html")
MAX_PAGES = Integer(ENV.fetch("MAX_PAGES", "10"))
TRACKING_KEYS = %w[utm_source utm_medium utm_campaign ref].freeze

def canonicalize(value, base)
  uri = URI.join(base, value)
  uri.fragment = nil
  pairs = URI.decode_www_form(uri.query.to_s)
  pairs.reject! { |key, _| TRACKING_KEYS.include?(key) }
  uri.query = pairs.empty? ? nil : URI.encode_www_form(pairs.sort)
  uri.normalize.to_s
end

def fetch(url)
  uri = URI(url)
  response = Net::HTTP.start(
    uri.host, uri.port, use_ssl: uri.scheme == "https",
    open_timeout: 5, read_timeout: 15
  ) { |http| http.get(uri.request_uri, { "User-Agent" => "AuthorizedCatalogCrawler/1.0" }) }
  raise "HTTP #{response.code} for #{url}" unless response.is_a?(Net::HTTPSuccess)
  response.body
end

start = canonicalize(START_URL, START_URL)
allowed_host = URI(start).host
queue = [start]
seen_urls = Set.new
seen_skus = Set.new
products = []

until queue.empty? || seen_urls.length >= MAX_PAGES
  url = queue.shift
  next if seen_urls.include?(url)
  raise "crawler left allowed host" unless URI(url).host == allowed_host

  seen_urls << url
  doc = Nokogiri::HTML5(fetch(url))
  puts "PAGE #{seen_urls.length} #{url}"

  doc.css("article.product").each do |node|
    sku = node["data-sku"]
    next if sku.to_s.empty? || seen_skus.include?(sku)

    seen_skus << sku
    products << {
      sku: sku,
      name: node.at_css(".name")&.text&.strip,
      price: node.at_css(".price")&.text&.strip,
      source_url: url
    }
    puts "  PRODUCT #{sku} #{products.last[:name]} #{products.last[:price]}"
  end

  next_link = doc.at_css('a[rel="next"]')&.[]("href")
  next unless next_link

  next_url = canonicalize(next_link, url)
  queue << next_url if URI(next_url).host == allowed_host && !seen_urls.include?(next_url)
end

puts "DONE pages=#{seen_urls.length} unique_products=#{products.length}"
ruby -c crawl_catalog.rb
MAX_PAGES=10 ruby crawl_catalog.rb http://127.0.0.1:8877/catalog-1.html

ruby-pagination-crawl-result

The actual result is 3 pages and 4 unique products. When PG-002 reappears on page 2, it isn’t written a second time. ref, utm_source, and the fragment are stripped, but the code doesn’t casually remove parameters that could change region, sort order, or product variant.

Hands-On 5: Scrape Infinite Scroll with Ruby and Deduplicate by SKU

Infinite scroll can’t be handled with a single scrollTo call, and you can’t rely only on comparing page height. The test page appends products in three batches, with IN-002 appearing twice. The script reads the current cards each round, adds their SKUs to a Set, scrolls to the bottom, and waits for either “more DOM cards” or an “end” flag; it also stops after two consecutive rounds with no new SKU.

# frozen_string_literal: true

require "selenium-webdriver"
require "set"

url = ARGV.fetch(0, "http://127.0.0.1:8877/infinite-products.html")
driver_path = ENV.fetch(
  "CHROMEDRIVER_BIN",
  File.expand_path("~/.cache/selenium/chromedriver/mac-arm64/151.0.7922.138/chromedriver")
)

options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--headless=new")
options.add_argument("--window-size=1280,900")
service = Selenium::WebDriver::Service.chrome(path: driver_path)
driver = Selenium::WebDriver.for(:chrome, options: options, service: service)

begin
  driver.navigate.to(url)
  seen = Set.new
  stable_rounds = 0

  10.times do |round|
    cards = driver.find_elements(css: "article.product")
    before = seen.length
    cards.each { |card| seen << card.attribute("data-sku") }
    puts "ROUND #{round + 1} dom=#{cards.length} unique=#{seen.length}"

    stable_rounds = seen.length == before ? stable_rounds + 1 : 0
    ended = driver.find_element(css: "#sentinel").text == "End of catalog"
    break if stable_rounds >= 2 || ended

    driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
    Selenium::WebDriver::Wait.new(timeout: 3).until do
      driver.find_elements(css: "article.product").length > cards.length ||
        driver.find_element(css: "#sentinel").text == "End of catalog"
    end
  end

  raise "no products loaded" if seen.empty?
  puts "DONE unique_skus=#{seen.to_a.sort.join(',')}"
ensure
  driver.quit
end
gem install selenium-webdriver
export CHROMEDRIVER_BIN=/path/to/chromedriver
ruby -c scrape_infinite_scroll.rb
ruby scrape_infinite_scroll.rb http://127.0.0.1:8877/infinite-products.html

selenium-infinite-scroll-result

Wrong Approach Why It Fails Correct Judgment
A fixed sleep before scraping once Network and rendering speed are inconsistent Explicitly wait for an element count, an end flag, or XHR completion
Comparing only DOM height Virtual lists may reuse nodes, and height may not change Compare new business IDs/SKUs and consecutive stable rounds
Deduplicating only in memory State is lost after a Job restart or across multiple workers Persist with a Redis/database unique index
Scrolling infinitely until an exception The page may cycle back to the same batch A triple budget: max_rounds, max_records, deadline

How Do You Split Multi-Page Discovery and Product Parsing into Jobs in Rails?

A production system should separate “discovering URLs” from “parsing products.” CatalogDiscoveryJob reads pagination at low concurrency and writes normalized URLs to a persistent queue; ProductPageJob handles only a single page and writes idempotently through a database unique index. This way, a single product’s parsing failure doesn’t force the whole catalog to rerun from page one.

class CatalogDiscoveryJob < ApplicationJob
  queue_as :crawler_discovery

  def perform(catalog_url, run_id:)
    result = CatalogCrawler.new.call(catalog_url, max_pages: 20)
    result.product_urls.each do |url|
      ScrapeProductPageJob.perform_later(url, run_id: run_id)
    end
    self.class.perform_later(result.next_url, run_id: run_id) if result.next_url
  end
end

class ScrapeProductPageJob < ApplicationJob
  queue_as :product_pages

  def perform(url, run_id:)
    row = ProductPageScraper.new.call(url)
    Product.upsert_all(
      [row.merge(run_id: run_id, updated_at: Time.current)],
      unique_by: :index_products_on_source_url_and_sku
    )
  end
end
  • Seen URLs must be persisted, or the Job will rediscover the same page after a restart.
  • Rate-limit the discovery queue and the browser queue separately; share a rate budget for the same target domain.
  • Store parent_url, depth, discovered_at, and run_id to make it easier to trace a URL’s origin.
  • Set max_pages, max_depth, max_records, and a deadline for each run; stop as soon as any budget is reached.

How Do You Implement Concurrency, Batching, and Domain Rate-Limiting in Ruby Scraping?

Concurrency suits independent HTTP requests that are waiting on network I/O — it doesn’t mean a browser task should scale to the same numbers. MRI Ruby’s threads can advance other requests while one is waiting on the network, but CPU-intensive parsing is still bound by the GVL. A more reliable approach in Rails is to let the queue control concurrency, and set a separate rate limit for each target domain.

require "thread"

queue = Queue.new
urls.each { |url| queue << url }

workers = Array.new([4, urls.length].min) do
  Thread.new do
    loop do
      url = queue.pop(true)
      ProductScraper.new.call(url)
      sleep 0.5 # Example only: use a domain-level rate limiter in production
    rescue ThreadError
      break
    end
  end
end
workers.each(&:join)
Task Type Starting Concurrency Strategy Must Observe Before Scaling
Ordinary HTTP 1–4 workers per domain, start with a small batch Valid-data rate, 429s, P95, response size
Browser tasks A dedicated queue, usually starting with 1–2 workers Memory, CPU, crash rate, wait timeouts
Database writes Bulk upsert, cap the batch size Lock waits, index hits, transaction time

When using exponential backoff, add random jitter, respect Retry-After, and set max_pages, max_records, and a deadline for a single run. Scale up based on valid records per minute and total cost — not raw request count alone.

How Do You Test, Debug, and Cache a Ruby on Rails Scraper?

The most important thing to test in a scraper is “can a saved response sample reliably parse into the field contract.” Keep network testing and parser testing separate: during development, cache the authorized response first, and let the parser’s unit tests read from a fixture; a smaller set of end-to-end tests then verifies the real network, queue, and database. This way you can still reproduce parsing issues when the page is briefly unavailable.

# spec/services/product_scraper_spec.rb
RSpec.describe ProductScraper do
  it "parses a saved product fixture" do
    html = File.read("spec/fixtures/product_catalog.html")
    rows = described_class.new.send(
      :parse, html, "https://example.test/catalog"
    )

    expect(rows.first).to have_attributes(
      sku: "NS-KEY-001",
      currency: "USD",
      price: BigDecimal("129.00")
    )
  end
end
  • Redact fixtures and record fetched_at, the template name, and the parser version — don’t keep unrelated personal data long-term.
  • Build failure tests for a missing root node, a challenge page, a soft 404, a currency change, and malformed JSON.
  • Cache successful responses in development and replay them offline when changing selectors; production caching must follow your data-retention rules.
  • Log run_id, domain, status, elapsed_ms, template, and validation_error — never output cookies, auth headers, or a proxy password.
  • Monitor valid_record_rate, required_field_missing_rate, and duplicate_rate, in addition to HTTP success rate.

How Should You Choose Between Ruby, Python, and JavaScript Scraping Stacks?

When a team already has a Rails application, a task queue, and Active Record models, Ruby reduces cross-language operational overhead. Python has a broader ecosystem for data processing, machine learning, and scraping frameworks. JavaScript/TypeScript is closest to the browser runtime and front-end debugging. The choice should be based on team capability and the runtime model — not language popularity.

Stack Where It Has an Edge Trade-off to Consider
Ruby / Rails An existing Rails product, background tasks, database workflows Fewer dedicated scraping frameworks and less of a data-science ecosystem
Python Scrapy, data analysis, ETL, connecting to machine learning If the main system is Rails, this adds a service boundary and deployment overhead
JavaScript/TypeScript Browser automation, front-end state, and network debugging CPU-intensive processing and the back-end data model need to be designed separately

A hybrid architecture is also common: Rails manages tasks, permissions, and results, while a separate Python/Node worker handles specific scraping. It’s only worth splitting when the benefit outweighs the cost of a messaging protocol, deployment, monitoring, and troubleshooting.

How Do You Improve the Reliability of Rails Scraping Jobs?

Risk What to Log Correct Handling
Connect/read timeout Exception type, elapsed time, target domain Limited retries + exponential backoff
429 Retry-After, queue rate Pause and lower concurrency for that domain
403/CAPTCHA Page classification, authorization status Pause and check permissions — don’t loop through exits
200 but empty fields Raw response sample, template version Distinguish JS rendering, a soft 404, and a selector change
Duplicate records Business unique key, run_id A unique index + upsert_all
Anomalous price Raw text, currency, region Re-verify before alerting

Why Might a Rails Scraping Project Need a Proxy?

A proxy is an optional network-routing layer within an authorized workflow — not a tool for bypassing a denial. A proxy has clear value when a project needs to verify publicly displayed prices across regions, provide an auditable exit for different projects, or hold a fixed region and session throughout a paginated flow. If you hit a 403, a 429, a CAPTCHA, or an explicit denial, pause first, verify authorization, and lower your request rate.

Rola IP’s web scraping proxy use case page describes network types and data-collection use cases; the actual target still needs its own authorization check and a small-scale acceptance test.

How Is Rola IP Used for Web Scraping with Ruby on Rails?

Rola IP covers 190+ countries and regions, with 80M+ residential IPs and a 99.9% uptime commitment, and supports country/city targeting, HTTP/SOCKS5, credential and whitelist authentication, per-request rotation, sticky sessions, 3,000+ concurrency, sub-accounts, and traffic quotas.

Rails Task Recommended Product Key Configuration Acceptance Metric
Region-specific product pages Dynamic residential Specify country/city; start with low concurrency Regional accuracy, valid-data rate
Pagination or a multi-step flow Sticky residential / ISP Fix the region for the same session Session-persistence rate, exit stability
Open, lightly protected pages Datacenter proxy Rate-limit by domain P95, success rate, cost
Mobile display verification Mobile proxy Only enable for an explicit mobile need Page version, region, traffic
Multi-team Rails Jobs Matching product + sub-account Project-level credentials and quota Budget isolation, auditing

For a fixed session, see ISP proxies; for your first integration, see proxy code integration.

rola-ip-dynamic-residential-proxy

Configuring Rola IP in Ruby

  1. In the Rola IP dashboard, choose the proxy type, target region, and rotation/sticky strategy.
  2. Create a proxy account or whitelist to get the host, port, username, and password.

rola-ip-create-proxy-account

  1. Store the credentials in Rails encrypted credentials or your deployment platform’s environment variables — not in source code.
  2. First hit an IP-lookup endpoint to verify the exit and region, then access an authorized test page at low concurrency.
  3. Log both the network result and valid fields in the body — a changed exit doesn’t by itself mean the scrape succeeded.
# frozen_string_literal: true

require "net/http"
require "uri"

def required(name)
  value = ENV[name]
  raise "Missing required environment variable: #{name}" if value.nil? || value.empty?

  value
end

proxy_host = required("ROLA_PROXY_HOST")
proxy_port = Integer(required("ROLA_PROXY_PORT"), 10)
proxy_user = required("ROLA_PROXY_USERNAME")
proxy_pass = required("ROLA_PROXY_PASSWORD")

uri = URI("https://httpbin.org/ip")
http = Net::HTTP::Proxy(proxy_host, proxy_port, proxy_user, proxy_pass).new(
  uri.host, uri.port
)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 30

request = Net::HTTP::Get.new(uri)
response = http.request(request)
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)

puts response.body

rails-rolaip-code-verification

Conclusion

Reliable ruby on rails web scraping isn’t about copying Nokogiri code straight into a Controller — it’s about building a complete chain of “access boundary + page identification + Service Object + Active Job + Active Record + quality monitoring + optional network routing.” This article’s static scraping and CSV export were actually run, and the Rails and Rola IP code passed syntax and failure-path verification. Before going live, run tests in your target project’s supported Ruby/Rails version, and validate with a small, representative, authorized batch of URLs.

Frequently asked questions