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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +93 -0
- data/lib/webcrawlerapi/client.rb +268 -0
- data/lib/webcrawlerapi/constants.rb +24 -0
- data/lib/webcrawlerapi/errors.rb +25 -0
- data/lib/webcrawlerapi/job.rb +81 -0
- data/lib/webcrawlerapi/model.rb +67 -0
- data/lib/webcrawlerapi/version.rb +5 -0
- data/lib/webcrawlerapi.rb +12 -0
- metadata +98 -0
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,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: []
|