webcrawlerapi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3acecf5769685f3baf072a393c7b9a997c91a896413729a12777fab44500ff6a
4
+ data.tar.gz: 6fefd3be1bdc588b9a0cbb5d124619576b4207f31d707fbfa4d77d42f5387a5e
5
+ SHA512:
6
+ metadata.gz: ba2c0ce429435fbfb2af6e45f7499f5d61c05716918ddd2ada0811e6937650da2bfc6aea55d85b10d3742bcd87364d2bf91a58b3dc9bf0a3001389a90860f38e
7
+ data.tar.gz: 7ff338ddc9e2d709843a19b863ec1ddd377a872217e2784af9a59cf28360dcf335a41997cc18d3e2031e2aaf5c22be9790f17ae3bf8adb262406be521e81f88b
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Batch scrape: `scrape(url: [...])`, `scrape_batch`, `scrape_batch_async`, `get_batch`, `get_batch_errors`, `cancel_batch`.
6
+
7
+ ## 0.1.0
8
+
9
+ - Initial release. Port of the JS SDK: `scrape`, `scrape_async`, `get_scrape`, `crawl`, `crawl_async`, `get_job`,
10
+ `get_job_markdown`, `get_job_markdown_content`, `markdown`, `run_agent`, `run_agent_async`, `get_agent_job`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebcrawlerAPI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # WebcrawlerAPI Ruby SDK
2
+
3
+ Ruby client for [WebcrawlerAPI](https://webcrawlerapi.com). No runtime dependencies, Ruby 3.0+.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ gem "webcrawlerapi"
9
+ ```
10
+
11
+ or `gem install webcrawlerapi`.
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "webcrawlerapi"
17
+
18
+ client = WebCrawlerAPI::Client.new("YOUR_API_KEY")
19
+
20
+ # Scrape one page (polls until done)
21
+ res = client.scrape(url: "https://example.com", output_formats: ["markdown"])
22
+ puts res.markdown
23
+
24
+ # Scrape many URLs: pass an array, polls until the batch finishes.
25
+ # Returns the first page of items (`data`) and the `next` cursor.
26
+ batch = client.scrape(url: ["https://example.com", "https://example.com/pricing"],
27
+ output_formats: ["markdown"], max_concurrency: 5)
28
+ batch.data.each { |item| puts "#{item['url']} #{item['status']} #{item['markdown_url']}" }
29
+ more = client.get_batch(batch.id, cursor: batch.next, limit: 100) unless batch.next.to_s.empty?
30
+
31
+ # Crawl a site (polls until the job finishes)
32
+ job = client.crawl(url: "https://example.com", items_limit: 10)
33
+ job.job_items.each do |item|
34
+ puts item.original_url
35
+ puts item.get_content # markdown by default
36
+ end
37
+
38
+ # Markdown of a single article
39
+ puts client.markdown(url: "https://example.com/post").markdown
40
+
41
+ # Agent
42
+ run = client.run_agent(prompt: "Find pricing of example.com", urls: ["https://example.com"], max_spend_usd: 1)
43
+ puts run.data
44
+ ```
45
+
46
+ ## Methods (same as the JS SDK, snake_case)
47
+
48
+ | JS | Ruby |
49
+ |----|------|
50
+ | `scrape` / `scrapeAsync` / `getScrape` | `scrape` / `scrape_async` / `get_scrape` |
51
+ | `crawl` / `crawlAsync` / `getJob` | `crawl` / `crawl_async` / `get_job` |
52
+ | `getJobMarkdown` / `getJobMarkdownContent` | `get_job_markdown` / `get_job_markdown_content` |
53
+ | `markdown` | `markdown` |
54
+ | `runAgent` / `runAgentAsync` / `getAgentJob` | `run_agent` / `run_agent_async` / `get_agent_job` |
55
+ | `item.getContent/getMarkdown/getCleaned/getHTML` | `item.get_content/get_markdown/get_cleaned/get_html` |
56
+
57
+ Batch scrape: `scrape(url: [...])` (or `scrape_batch`) waits for the batch; `scrape_batch_async`, `get_batch(id, limit:, cursor:)`,
58
+ `get_batch_errors(id)` and `cancel_batch(id)` cover the non-waiting flow. Batch-only options: `max_concurrency`,
59
+ `ignore_invalid_urls`, `webhook_url`.
60
+
61
+ Full batch scrape documentation: [docs/batch-scrape.md](docs/batch-scrape.md).
62
+
63
+ `scrape`, `crawl` and `run_agent` take an optional second argument `max_polls`.
64
+ Responses are `WebCrawlerAPI::Model` objects: `res.markdown`, `res[:markdown]` or `res.to_h`.
65
+
66
+ ## Errors
67
+
68
+ Every failure raises `WebCrawlerAPI::Error` with `error_code`, `error_message`, `status_code`
69
+ (`network_error`, `timeout`, `invalid_response` are raised client-side).
70
+
71
+ ```ruby
72
+ begin
73
+ client.crawl(url: "https://example.com")
74
+ rescue WebCrawlerAPI::Error => e
75
+ warn "#{e.error_code} (#{e.status_code}): #{e.error_message}"
76
+ end
77
+ ```
78
+
79
+ ## Options
80
+
81
+ ```ruby
82
+ WebCrawlerAPI::Client.new("KEY", base_path: "https://api.webcrawlerapi.com", api_version: "v1",
83
+ open_timeout: 10, read_timeout: 60)
84
+ ```
85
+
86
+ ## Development
87
+
88
+ ```sh
89
+ bundle install
90
+ bundle exec rake test
91
+ ```
92
+
93
+ MIT license.
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module WebCrawlerAPI
8
+ class Client
9
+ NO_CACHE_HEADERS = {
10
+ "Cache-Control" => "no-cache, no-store, must-revalidate",
11
+ "Pragma" => "no-cache",
12
+ "Expires" => "0"
13
+ }.freeze
14
+
15
+ # @param api_key [String]
16
+ # @param base_path [String]
17
+ # @param api_version [String]
18
+ # @param open_timeout [Numeric] seconds
19
+ # @param read_timeout [Numeric] seconds
20
+ # @param sleeper [#call] called with seconds to wait between polls (override in tests)
21
+ def initialize(api_key, base_path: BASE_PATH, api_version: "v1", open_timeout: 10, read_timeout: 60,
22
+ sleeper: Kernel.method(:sleep))
23
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
24
+
25
+ @api_key = api_key
26
+ @base_path = base_path.to_s.chomp("/")
27
+ @api_version = api_version
28
+ @open_timeout = open_timeout
29
+ @read_timeout = read_timeout
30
+ @sleeper = sleeper
31
+ end
32
+
33
+ # ---- Scrape -----------------------------------------------------------
34
+
35
+ # Starts a scrape and returns its id.
36
+ # @return [ScrapeId]
37
+ def scrape_async(request)
38
+ data = send_request(:post, "#{@base_path}/#{SCRAPE_VERSION}/scrape?async=true", body: request)
39
+ ScrapeId.new("id" => data["id"])
40
+ end
41
+
42
+ # Returns the scrape result when done/error, otherwise an in-progress placeholder.
43
+ # @return [ScrapeResponse]
44
+ def get_scrape(scrape_id)
45
+ data = send_request(:get, "#{@base_path}/#{SCRAPE_VERSION}/scrape/#{scrape_id}", no_cache: true)
46
+ status = data["status"]
47
+ return ScrapeResponse.new(data) if %w[done error].include?(status)
48
+
49
+ ScrapeResponse.new(
50
+ "success" => false,
51
+ "status" => status,
52
+ "page_status_code" => 0,
53
+ "recommended_pull_delay_ms" => data["recommended_pull_delay_ms"]
54
+ )
55
+ end
56
+
57
+ # Starts a scrape and polls until done, error or timeout. Returns the last known state on timeout.
58
+ #
59
+ # If +request[:url]+ is an Array of URLs this creates a batch scrape instead (see #scrape_batch_async),
60
+ # polls until the batch leaves +scraping+ and returns the first page of items plus the +next+ cursor
61
+ # (use #get_batch with +cursor:+ for more). Raises a +timeout+ Error if the batch is still running
62
+ # after +max_polls+ polls. Batch-only options (+max_concurrency+, +webhook_url+, +ignore_invalid_urls+)
63
+ # go in the same request hash.
64
+ # @return [ScrapeResponse, BatchScrapeResponse]
65
+ def scrape(request, max_polls = SCRAPE_MAX_POLLS)
66
+ return scrape_batch(request, max_polls) if request[:url].is_a?(Array) || request["url"].is_a?(Array)
67
+
68
+ scrape_id = scrape_async(request)[:id]
69
+ started = monotonic_now
70
+ polls = 0
71
+ result = nil
72
+
73
+ while polls < max_polls && monotonic_now - started < SCRAPE_MAX_WAIT_SECONDS
74
+ result = get_scrape(scrape_id)
75
+ return result if %w[done error].include?(result[:status]) || result.key?(:error_code)
76
+
77
+ recommended = result[:recommended_pull_delay_ms]
78
+ delay = recommended.to_f.positive? ? recommended / 1000.0 : DEFAULT_POLL_DELAY_SECONDS
79
+ remaining = SCRAPE_MAX_WAIT_SECONDS - (monotonic_now - started)
80
+ break if remaining <= 0
81
+
82
+ @sleeper.call([delay, remaining].min)
83
+ polls += 1
84
+ end
85
+
86
+ result
87
+ end
88
+
89
+ # ---- Batch scrape -----------------------------------------------------
90
+
91
+ # Creates a batch scrape (POST /v2/batch/scrape) and returns +id+, +status_url+ and +invalid_urls+.
92
+ # The URL list is taken from +url+ (Array) or +urls+.
93
+ # @return [BatchScrapeId]
94
+ def scrape_batch_async(request)
95
+ body = stringify_keys(request)
96
+ urls = body.delete("url")
97
+ body["urls"] = urls if urls.is_a?(Array)
98
+ BatchScrapeId.new(send_request(:post, "#{@base_path}/#{SCRAPE_VERSION}/batch/scrape", body: body))
99
+ end
100
+
101
+ # Returns batch progress and one page of items. +next+ is the cursor for the following page ("" on the last).
102
+ # @param limit [Integer, nil] items per page, 1 to 500
103
+ # @param cursor [String, nil] +next+ value from the previous page
104
+ # @return [BatchScrapeResponse]
105
+ def get_batch(batch_id, limit: nil, cursor: nil)
106
+ query = URI.encode_www_form({ "limit" => limit, "cursor" => cursor }.compact)
107
+ url = "#{@base_path}/#{SCRAPE_VERSION}/batch/scrape/#{batch_id}"
108
+ url += "?#{query}" unless query.empty?
109
+ BatchScrapeResponse.new(send_request(:get, url, no_cache: true))
110
+ end
111
+
112
+ # Lists failed URLs (+errors+) and URLs blocked by robots.txt (+robots_blocked+).
113
+ # @return [BatchScrapeErrors]
114
+ def get_batch_errors(batch_id)
115
+ BatchScrapeErrors.new(send_request(:get, "#{@base_path}/#{SCRAPE_VERSION}/batch/scrape/#{batch_id}/errors",
116
+ no_cache: true))
117
+ end
118
+
119
+ # Cancels a running batch. Returns +{"status" => "canceled"}+.
120
+ # @return [Model]
121
+ def cancel_batch(batch_id)
122
+ Model.new(send_request(:delete, "#{@base_path}/#{SCRAPE_VERSION}/batch/scrape/#{batch_id}"))
123
+ end
124
+
125
+ # Creates a batch and polls until it is no longer +scraping+. Same as +scrape(url: [...])+.
126
+ # @return [BatchScrapeResponse]
127
+ def scrape_batch(request, max_polls = SCRAPE_MAX_POLLS)
128
+ batch_id = scrape_batch_async(request)[:id]
129
+ raise Error.new("invalid_response", "Failed to create batch", 0) if batch_id.nil? || batch_id.empty?
130
+
131
+ max_polls.times do
132
+ batch = get_batch(batch_id)
133
+ return batch unless batch[:status] == BATCH_SCRAPING_STATUS
134
+
135
+ @sleeper.call(DEFAULT_POLL_DELAY_SECONDS)
136
+ end
137
+
138
+ raise Error.new("timeout", "Batch scrape took too long, please retry or increase the number of polling retries", 0)
139
+ end
140
+
141
+ # ---- Crawl ------------------------------------------------------------
142
+
143
+ # Starts a crawl and polls until the job leaves new/in_progress.
144
+ # @return [Job]
145
+ def crawl(request, max_polls = MAX_PULL_RETRIES)
146
+ job_id = crawl_async(request)[:id]
147
+ raise Error.new("invalid_response", "Failed to fetch job status", 0) if job_id.nil? || job_id.empty?
148
+
149
+ delay = INITIAL_PULL_DELAY_SECONDS
150
+ max_polls.times do
151
+ @sleeper.call(delay)
152
+ job = get_job(job_id)
153
+ return job unless [JobStatus::IN_PROGRESS, JobStatus::NEW].include?(job[:status])
154
+
155
+ delay = job[:recommended_pull_delay_ms] / 1000.0 if job[:recommended_pull_delay_ms].to_i.positive?
156
+ end
157
+
158
+ raise Error.new("timeout", "Crawling took too long, please retry or increase the number of polling retries", 0)
159
+ end
160
+
161
+ # @return [JobId]
162
+ def crawl_async(request)
163
+ body = { "output_formats" => ["markdown"] }.merge(stringify_keys(request))
164
+ JobId.new(send_request(:post, "#{@base_path}/#{@api_version}/crawl", body: body))
165
+ end
166
+
167
+ # @return [Job]
168
+ def get_job(job_id)
169
+ Job.new(send_request(:get, "#{@base_path}/#{@api_version}/job/#{job_id}", no_cache: true))
170
+ end
171
+
172
+ # @return [JobMarkdownResponse]
173
+ def get_job_markdown(job_id)
174
+ JobMarkdownResponse.new(send_request(:get, "#{@base_path}/#{@api_version}/job/#{job_id}/markdown", no_cache: true))
175
+ end
176
+
177
+ # @return [String]
178
+ def get_job_markdown_content(job_id)
179
+ send_request(:get, "#{@base_path}/#{@api_version}/job/#{job_id}/markdown/content", no_cache: true, raw: true)
180
+ end
181
+
182
+ # ---- Markdown ---------------------------------------------------------
183
+
184
+ # @return [MarkdownResponse]
185
+ def markdown(request)
186
+ MarkdownResponse.new(send_request(:post, "#{@base_path}/markdown", body: request))
187
+ end
188
+
189
+ # ---- Agent ------------------------------------------------------------
190
+
191
+ # @return [AgentRun]
192
+ def run_agent_async(request)
193
+ AgentRun.new(send_request(:post, "#{@base_path}/#{@api_version}/agent", body: request))
194
+ end
195
+
196
+ # @return [AgentRun]
197
+ def get_agent_job(job_id)
198
+ AgentRun.new(send_request(:get, "#{@base_path}/#{@api_version}/agent/job/#{job_id}", no_cache: true))
199
+ end
200
+
201
+ # Runs an agent and polls until done, error or canceled.
202
+ # @return [AgentRun]
203
+ def run_agent(request, max_polls = MAX_PULL_RETRIES)
204
+ run = run_agent_async(request)
205
+ raise Error.new("invalid_response", "Failed to fetch agent job status", 0) if run[:id].nil? || run[:id].empty?
206
+ return run if AGENT_TERMINAL_STATUSES.include?(run[:status])
207
+
208
+ max_polls.times do
209
+ @sleeper.call(DEFAULT_POLL_DELAY_SECONDS)
210
+ agent_job = get_agent_job("#{run[:id]}?t=#{(Time.now.to_f * 1000).to_i}")
211
+ return agent_job if AGENT_TERMINAL_STATUSES.include?(agent_job[:status])
212
+ end
213
+
214
+ raise Error.new("timeout", "Agent run took too long, please retry or increase the number of polling retries", 0)
215
+ end
216
+
217
+ private
218
+
219
+ def monotonic_now
220
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
221
+ end
222
+
223
+ def stringify_keys(hash)
224
+ JSON.parse(JSON.generate(hash))
225
+ end
226
+
227
+ def headers(no_cache)
228
+ base = {
229
+ "Content-Type" => "application/json",
230
+ "Authorization" => "Bearer #{@api_key}",
231
+ "User-Agent" => "WebcrawlerAPI-Ruby-Client/#{VERSION}"
232
+ }
233
+ no_cache ? base.merge(NO_CACHE_HEADERS) : base
234
+ end
235
+
236
+ def send_request(method, url, body: nil, no_cache: false, raw: false)
237
+ uri = URI.parse(url)
238
+ klass = { post: Net::HTTP::Post, delete: Net::HTTP::Delete }.fetch(method, Net::HTTP::Get)
239
+ req = klass.new(uri, headers(no_cache))
240
+ req.body = JSON.generate(body) if body
241
+
242
+ response = begin
243
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
244
+ open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
245
+ http.request(req)
246
+ end
247
+ rescue SystemCallError, SocketError, Timeout::Error, IOError, OpenSSL::SSL::SSLError => e
248
+ raise Error.new("network_error", "Failed to send request: #{e.message}", 0)
249
+ end
250
+
251
+ raise error_from(response) unless response.is_a?(Net::HTTPSuccess)
252
+
253
+ text = response.body.to_s.dup.force_encoding(Encoding::UTF_8)
254
+ return text if raw
255
+
256
+ JSON.parse(text)
257
+ rescue JSON::ParserError
258
+ raise Error.new("invalid_response", "Response is not valid JSON", response.code.to_i)
259
+ end
260
+
261
+ def error_from(response)
262
+ data = JSON.parse(response.body.to_s)
263
+ Error.from_response(response.code.to_i, data)
264
+ rescue JSON::ParserError
265
+ Error.new("unknown_error", "Request failed with status #{response.code} #{response.message}", response.code.to_i)
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebCrawlerAPI
4
+ module JobStatus
5
+ IN_PROGRESS = "in_progress"
6
+ NEW = "new"
7
+ DONE = "done"
8
+ end
9
+
10
+ module ErrorCode
11
+ BLOCKED_BY_ROBOTS_TXT = "blocked_by_robots_txt"
12
+ end
13
+
14
+ BASE_PATH = "https://api.webcrawlerapi.com"
15
+ SCRAPE_VERSION = "v2"
16
+ INITIAL_PULL_DELAY_SECONDS = 2.0
17
+ MAX_PULL_RETRIES = 100
18
+ DEFAULT_POLL_DELAY_SECONDS = 2.0
19
+ SCRAPE_MAX_WAIT_SECONDS = 400.0
20
+ # Effectively unbounded by count at the 2s default; elapsed time is the real limit.
21
+ SCRAPE_MAX_POLLS = 1000
22
+ BATCH_SCRAPING_STATUS = "scraping"
23
+ AGENT_TERMINAL_STATUSES = %w[done error canceled].freeze
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebCrawlerAPI
4
+ # Raised for every API, network and polling failure.
5
+ class Error < StandardError
6
+ attr_reader :error_code, :error_message, :status_code
7
+
8
+ def initialize(error_code, error_message, status_code = nil)
9
+ super(error_message)
10
+ @error_code = error_code
11
+ @error_message = error_message
12
+ @status_code = status_code
13
+ end
14
+
15
+ # Builds an error from a parsed API error body (Hash with string keys).
16
+ def self.from_response(status_code, data)
17
+ data = {} unless data.is_a?(Hash)
18
+ new(
19
+ data["error_code"] || "unknown_error",
20
+ data["error_message"] || data["error"] || "Unknown error",
21
+ status_code
22
+ )
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module WebCrawlerAPI
7
+ class JobItem < Model
8
+ def initialize(attributes, job)
9
+ super(attributes)
10
+ @job = job
11
+ end
12
+
13
+ # Content in the job's preferred format (markdown > cleaned > html). nil unless job and item are done.
14
+ def get_content
15
+ return nil unless @job[:status] == JobStatus::DONE && self[:status] == JobStatus::DONE
16
+
17
+ ContentFetcher.fetch(@job.resolve_content_url(self))
18
+ end
19
+
20
+ def get_markdown
21
+ ContentFetcher.fetch(self[:markdown_content_url])
22
+ end
23
+
24
+ def get_cleaned
25
+ ContentFetcher.fetch(self[:cleaned_content_url])
26
+ end
27
+
28
+ def get_html
29
+ ContentFetcher.fetch(self[:raw_content_url])
30
+ end
31
+ end
32
+
33
+ class Job < Model
34
+ CONTENT_PRIORITY = [
35
+ ["markdown", :markdown_content_url],
36
+ ["cleaned", :cleaned_content_url],
37
+ ["html", :raw_content_url]
38
+ ].freeze
39
+
40
+ SCRAPE_TYPE_URLS = {
41
+ "html" => :raw_content_url,
42
+ "cleaned" => :cleaned_content_url,
43
+ "markdown" => :markdown_content_url
44
+ }.freeze
45
+
46
+ def job_items
47
+ @job_items ||= Array(self["job_items"]).map { |item| JobItem.new(item, self) }
48
+ end
49
+
50
+ # @api private
51
+ def resolve_content_url(item)
52
+ formats = self[:output_formats]
53
+ if formats.is_a?(Array) && !formats.empty?
54
+ fmt, reader = CONTENT_PRIORITY.find { |name, _| formats.include?(name) }
55
+ return fmt ? item[reader] : nil
56
+ end
57
+
58
+ reader = SCRAPE_TYPE_URLS[self["scrape_type"]]
59
+ reader && item[reader]
60
+ end
61
+ end
62
+
63
+ # @api private
64
+ module ContentFetcher
65
+ def self.fetch(url)
66
+ return nil if url.nil? || url.to_s.empty?
67
+
68
+ uri = URI.parse(url)
69
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
70
+ http.request(Net::HTTP::Get.new(uri, "Accept" => "*/*"))
71
+ end
72
+ unless response.is_a?(Net::HTTPSuccess)
73
+ raise Error.new("fetch_error", "Failed to fetch content: #{response.message}", response.code.to_i)
74
+ end
75
+
76
+ response.body.to_s.dup.force_encoding(Encoding::UTF_8)
77
+ rescue SystemCallError, SocketError, Timeout::Error, IOError, OpenSSL::SSL::SSLError => e
78
+ raise Error.new("network_error", "Failed to fetch content: #{e.message}", 0)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebCrawlerAPI
4
+ # Thin read-only wrapper over an API JSON object. Fields are available as
5
+ # methods (+job.status+), by key (+job["status"]+ / +job[:status]+) or via +to_h+.
6
+ class Model
7
+ # Declares documented optional fields: readers return nil when the API omits them.
8
+ def self.optional_attributes(*names)
9
+ names.each { |name| define_method(name) { @attributes[name.to_s] } }
10
+ end
11
+
12
+ def initialize(attributes)
13
+ @attributes = attributes.is_a?(Hash) ? attributes : {}
14
+ end
15
+
16
+ def [](key)
17
+ @attributes[key.to_s]
18
+ end
19
+
20
+ def to_h
21
+ @attributes
22
+ end
23
+
24
+ def key?(key)
25
+ @attributes.key?(key.to_s)
26
+ end
27
+
28
+ def ==(other)
29
+ other.is_a?(self.class) && other.to_h == to_h
30
+ end
31
+
32
+ def method_missing(name, *args)
33
+ return super unless args.empty? && @attributes.key?(name.to_s)
34
+
35
+ @attributes[name.to_s]
36
+ end
37
+
38
+ def respond_to_missing?(name, include_private = false)
39
+ @attributes.key?(name.to_s) || super
40
+ end
41
+
42
+ def inspect
43
+ "#<#{self.class.name} #{@attributes.inspect}>"
44
+ end
45
+ end
46
+
47
+ class ScrapeResponse < Model
48
+ optional_attributes :success, :status, :markdown, :cleaned_content, :raw_content, :page_status_code,
49
+ :page_title, :structured_data, :links, :recommended_pull_delay_ms,
50
+ :error_code, :error_message
51
+ end
52
+ class AgentRun < Model
53
+ optional_attributes :data, :error_reason, :max_spend_usd, :urls
54
+ end
55
+ class MarkdownResponse < Model
56
+ optional_attributes :markdown
57
+ end
58
+ class JobMarkdownResponse < Model; end
59
+ class ScrapeId < Model; end
60
+ class JobId < Model; end
61
+ class BatchScrapeId < Model; end
62
+ class BatchScrapeResponse < Model
63
+ # +next+ is absent on the last page.
64
+ optional_attributes :next, :data
65
+ end
66
+ class BatchScrapeErrors < Model; end
67
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebCrawlerAPI
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "webcrawlerapi/version"
4
+ require_relative "webcrawlerapi/constants"
5
+ require_relative "webcrawlerapi/errors"
6
+ require_relative "webcrawlerapi/model"
7
+ require_relative "webcrawlerapi/job"
8
+ require_relative "webcrawlerapi/client"
9
+
10
+ # Ruby client for WebcrawlerAPI. See WebCrawlerAPI::Client.
11
+ module WebCrawlerAPI
12
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: webcrawlerapi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - WebcrawlerAPI
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-11-05 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.20'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.20'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: webmock
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.19'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.19'
54
+ description: Crawl and scrape websites, convert pages to markdown and run AI agents
55
+ with WebcrawlerAPI.
56
+ email:
57
+ - sdk@webcrawlerapi.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE
64
+ - README.md
65
+ - lib/webcrawlerapi.rb
66
+ - lib/webcrawlerapi/client.rb
67
+ - lib/webcrawlerapi/constants.rb
68
+ - lib/webcrawlerapi/errors.rb
69
+ - lib/webcrawlerapi/job.rb
70
+ - lib/webcrawlerapi/model.rb
71
+ - lib/webcrawlerapi/version.rb
72
+ homepage: https://webcrawlerapi.com
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://webcrawlerapi.com
77
+ source_code_uri: https://github.com/WebCrawlerAPI/webcrawlerapi-ruby-sdk
78
+ changelog_uri: https://github.com/WebCrawlerAPI/webcrawlerapi-ruby-sdk/blob/main/CHANGELOG.md
79
+ documentation_uri: https://webcrawlerapi.com/docs
80
+ rubygems_mfa_required: 'true'
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 3.0.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.6.2
96
+ specification_version: 4
97
+ summary: Ruby client for WebcrawlerAPI
98
+ test_files: []