Skip to content

Commit 24755ed

Browse files
thatbudakguyclaude
andcommitted
Apply crawl_delay per request instead of per page
Moves the requests a harvest makes into an HttpClient that waits out the crawl delay before each one. Previously the delay was applied once per page of search results, so a Blacklight 7 harvest could issue 100 document requests back to back before it took effect. Each request continues to get its own connection rather than reusing one, which is likewise more likely to be accepted by a WAF; the client is now the single place that behavior is decided. Fractional delays work too. A response class builds a client for itself when it isn't given one, and passes along the logger it was given so request logging doesn't quietly go somewhere else. Harvester request specs now stub HTTP with webmock rather than mocking Net::HTTP.get, since the request path is no longer a single class method. Closes #209 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2ef3e9a commit 24755ed

3 files changed

Lines changed: 190 additions & 52 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ end
191191

192192
##### Crawl Delays (default: none)
193193

194-
Crawl delays can be configured (in seconds) either globally for all sites or on a per-site basis. This will cause a delay for that number of seconds between each search results page (note that Blacklight 7 necessitates a lot of requests per results page and this only causes the delay per page of results)
194+
Crawl delays can be configured (in seconds, and fractions of a second are allowed) either globally for all sites or on a per-site basis. The harvester waits out the delay before every request it makes, not just before each page of search results -- which matters because Blacklight 7 and above needs a request per document, so one page of results is many requests. Each request also gets its own connection instead of reusing one. Together, pacing requests and connecting fresh make a harvest much less likely to be turned away by a WAF or other bot mitigation.
195+
196+
Be aware that this makes a harvest take considerably longer than it did when the delay applied per page: a one second delay against a Blacklight 7 site with 10,000 records is around three hours of waiting. Lower the delay if that matters more to you than getting past bot mitigation.
195197

196198
##### Solr's commitWithin (default: 5000 milliseconds)
197199

lib/geo_combine/geo_blacklight_harvester.rb

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require 'net/http'
34
require 'geo_combine/logger'
45

56
module GeoCombine
@@ -13,7 +14,7 @@ module GeoCombine
1314
# end
1415
# The class configuration also allows for various other things to be configured:
1516
# - A debug parameter to print out details of what is being harvested and indexed
16-
# - crawl delays for each page of results (globally or on a per site basis)
17+
# - crawl delays between requests (globally or on a per site basis)
1718
# - Solr's commitWithin parameter (defaults to 5000)
1819
# - A document transformer proc to modify a document before indexing (defaults to removing _version_, score, and timestamp)
1920
# Example: GeoCombine::GeoBlacklightHarvester.new('SITE').index
@@ -74,6 +75,41 @@ def each_document(&block)
7475
each_page { |documents| documents.each(&block) }
7576
end
7677

78+
##
79+
# Makes the requests for a harvest, waiting out the configured crawl delay
80+
# before each one. Each request gets its own connection instead of reusing
81+
# one; a request that is both paced and freshly connected is much less
82+
# likely to be turned away by a WAF or other bot mitigation.
83+
class HttpClient
84+
attr_reader :crawl_delay
85+
86+
def initialize(crawl_delay: nil, logger: GeoCombine::Logger.logger)
87+
@crawl_delay = crawl_delay&.to_f
88+
@logger = logger
89+
end
90+
91+
# Fetch a URL and parse the JSON response body
92+
def get_json(url)
93+
JSON.parse(get(url))
94+
end
95+
96+
private
97+
98+
# Fetch a URL and return the response body
99+
def get(url)
100+
throttle
101+
Net::HTTP.get_response(URI(url)).body
102+
end
103+
104+
# Wait out the crawl delay, if one is configured
105+
def throttle
106+
return unless crawl_delay
107+
108+
@logger.debug "waiting #{crawl_delay}s before the next request"
109+
sleep(crawl_delay)
110+
end
111+
end
112+
77113
##
78114
# A "factory" class to determine the blacklight response version to use
79115
class BlacklightResponseVersionFactory
@@ -91,12 +127,13 @@ def self.call(json)
91127
end
92128

93129
class LegacyBlacklightResponse
94-
attr_reader :base_url
130+
attr_reader :base_url, :client
95131
attr_accessor :response, :page
96132

97-
def initialize(response:, base_url:, logger: GeoCombine::Logger.logger)
133+
def initialize(response:, base_url:, logger: GeoCombine::Logger.logger, client: HttpClient.new(logger:))
98134
@base_url = base_url
99135
@response = response
136+
@client = client
100137
@page = 1
101138
@logger = logger
102139
end
@@ -113,7 +150,7 @@ def documents
113150
@logger.debug "fetching page #{page} @ #{url}"
114151

115152
begin
116-
self.response = JSON.parse(Net::HTTP.get(URI(url)))
153+
self.response = client.get_json(url)
117154
rescue StandardError => e
118155
@logger.error "request for #{url} failed with #{e}"
119156
self.response = nil
@@ -139,12 +176,13 @@ def total_pages
139176
##
140177
# Class to return documents from the Blacklight API (v7 and above)
141178
class ModernBlacklightResponse
142-
attr_reader :base_url
179+
attr_reader :base_url, :client
143180
attr_accessor :response, :page
144181

145-
def initialize(response:, base_url:, logger: GeoCombine::Logger.logger)
182+
def initialize(response:, base_url:, logger: GeoCombine::Logger.logger, client: HttpClient.new(logger:))
146183
@base_url = base_url
147184
@response = response
185+
@client = client
148186
@page = 1
149187
@logger = logger
150188
end
@@ -164,7 +202,7 @@ def documents
164202
self.page += 1
165203
@logger.debug "fetching page #{page} @ #{url}"
166204
begin
167-
self.response = JSON.parse(Net::HTTP.get(URI(url)))
205+
self.response = client.get_json(url)
168206
rescue StandardError => e
169207
@logger.error "Request for #{url} failed with #{e}"
170208
self.response = nil
@@ -177,7 +215,7 @@ def documents
177215
def documents_from_urls(urls)
178216
@logger.debug "fetching #{urls.count} documents for page #{page}"
179217
urls.map do |url|
180-
JSON.parse(Net::HTTP.get(URI("#{url}/raw")))
218+
client.get_json("#{url}/raw")
181219
rescue StandardError => e
182220
@logger.error "fetching \"#{url}/raw\" failed with #{e}"
183221

@@ -193,16 +231,19 @@ def each_page
193231
return to_enum(:each_page) unless block_given?
194232

195233
@logger.debug "fetching page 1 @ #{base_url}&page=1"
196-
response = JSON.parse(Net::HTTP.get(URI("#{base_url}&page=1")))
234+
response = client.get_json("#{base_url}&page=1")
197235
response_class = BlacklightResponseVersionFactory.call(response)
198236

199-
response_class.new(response:, base_url:, logger: @logger).documents.each do |documents|
237+
response_class.new(response:, base_url:, client:, logger: @logger).documents.each do |documents|
200238
yield documents.map { |document| self.class.document_transformer&.call(document) }.compact
201-
202-
sleep(crawl_delay.to_i) if crawl_delay
203239
end
204240
end
205241

242+
# The client used to make requests for this site
243+
def client
244+
@client ||= HttpClient.new(crawl_delay:, logger: @logger)
245+
end
246+
206247
def base_url
207248
"#{site[:host]}?#{default_params.to_query}"
208249
end

0 commit comments

Comments
 (0)