webscraping_ai 4.1.0 → 4.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd7230f48fdc600563c0e7fe84ba65411f6363007854a9bdaae6b2ee6bfbf17d
4
- data.tar.gz: a815bbc4645cd9eb23aba4e9cdafff0ac1c420f3a230580e002a59f7f19b28f6
3
+ metadata.gz: bd048e4bbb3a0bec22e8dac6616be20895e1c78761773295f164746985468261
4
+ data.tar.gz: 97972326da495b82684af9a58c95f9d0c3220d1b02559b54b1211381d5e16727
5
5
  SHA512:
6
- metadata.gz: 8266c1f95d1432a37f817e3308e1c99c1795fba17fc105c4ef703561e89f84104f0d2734fda99dded525000baecd346d00c2ac3f0b838e251807d3651d20fae6
7
- data.tar.gz: 6d075d815694065fde5b830d577100c8c45c9943798689ecc50b9959120d0486b10ede96b77344fb0803b8094f2855cf60d72237161b07a480e31b4531c62b1c
6
+ metadata.gz: 26d0348149011fcdb606717b7e4c8c178e9f21f544c0f720842678411c900dbecc673ad3046f0e0847704e818bdd6cee4aa9135e9c1e657bddd4e0cd109a1b82
7
+ data.tar.gz: f02a194c0a72c510f06da7ef20d0aa6019e89caf272494349a4b3a89ad0ed3f9c6a53ef1ed768f55d9edc6b40ceaef735080da5fb7fb5832df18b749b8b3fa84
data/CHANGELOG.md CHANGED
@@ -2,12 +2,33 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 4.3.0 — 2026-10-24
6
+ ### Added
7
+
8
+ - `Client#serp` gains `pages:`, `location:`, `uule:`, `lat:`, `lon:` and `radius:`. Every search runs from one fixed location (`location`, `lat` + `lon`, or `uule`, otherwise the capital of the `gl` country), so repeats and later pages are consistent; `gl` defaults to the location's country. `pages` (1–5) fetches consecutive pages as one de-duplicated list, billed per results page returned (see https://webscraping.ai/docs#serp). Raises `ArgumentError` when `pages` or `radius` is not an Integer >= 1, or when `lat`/`lon` is not a finite number. `lat`/`lon` are sent as plain decimals rounded to 7 places (never exponent notation). Ranges and conflicting location options are left to the server's 400 (not charged). New response keys are documented in the README (`search_parameters.location_used`, `organic_results[].page`, `pagination.pages_returned`, ...).
9
+ - `Client#serp_locations(q = nil, limit: nil)` for the new `GET /serp/locations` endpoint — returns an Array of matching locations (`id`, `canonical_name`, `country_code`, `target_type`, `reach`, `gps`, ...), most populous first; pass `canonical_name` or `id` as `serp`'s `location:`. Free, and the endpoint needs no API key. Raises `ArgumentError` when `limit` is not an Integer >= 1.
10
+ - `bin/smoke.rb` checks a two-page SERP from Austin and `serp_locations("austin", limit: 3)`.
11
+
12
+ ### Changed
13
+
14
+ - Docs: stop stating credit prices (they're set server-side and change); link to https://webscraping.ai/docs pricing instead.
15
+
16
+ ## 4.2.0 — 2026-09-25
17
+ ### Added
18
+
19
+ - `Client#data(url, country: nil, transcript: nil, transcript_language: nil, **params)` for the `GET /data` endpoint — structured JSON for a page on a supported site (e.g. YouTube, TikTok, X, LinkedIn, Instagram, Reddit) as a Hash with `request_parameters`, `parse_status` and `data`. Priced per site (see https://webscraping.ai/docs#data). The client does not check the URL's site: new sites are added server-side, and an unsupported URL or page type returns a 400 that is not charged (`BadRequestError`); its message lists what is supported. Raises `ArgumentError` when `url` is blank or not a String. Extra keyword arguments are sent as-is as query params (String, Integer, Float or boolean; Floats are sent as plain decimals). `api_key`, `url`, `country`, `transcript` and `transcript_language` as extra params raise `ArgumentError` (use the named options).
20
+ - `bin/smoke.rb` checks `/data` on a YouTube video and that `https://example.com/` gets the server's 400 "Unsupported URL" error.
21
+
22
+ ### Fixed
23
+
24
+ - `TimeoutError` and `ConnectionError` redact `api_key=...` from the transport error's message and no longer carry the Faraday exception as `cause`, since it may include the request URL.
25
+
5
26
  ## 4.1.0 — 2026-09-25
6
27
 
7
28
  ### Added
8
29
 
9
- - `Client#serp(q:, engine: nil, gl: nil, hl: nil, page: nil)` for the new `GET /serp` endpoint — parsed Google search results (organic results, related searches, pagination) as a Hash. Flat 15 credits per search. Raises `ArgumentError` when `q` is blank or not a String, or when `page` is not an Integer >= 1 (the server also rejects it with a 400, not billed; checking client-side saves the round trip). Pages are 1–100: the server rejects a `page` above 100 with a 400.
10
- - `bin/smoke.rb` live smoke script: asserts result shapes (not just the absence of exceptions), runs page tools with `js: false` on datacenter proxies (~32 credits per sweep), and redacts the API key from failure output.
30
+ - `Client#serp(q:, engine: nil, gl: nil, hl: nil, page: nil)` for the new `GET /serp` endpoint — parsed Google search results (organic results, related searches, pagination) as a Hash. Priced per search (see https://webscraping.ai/docs#serp). Raises `ArgumentError` when `q` is blank or not a String, or when `page` is not an Integer >= 1 (the server also rejects it with a 400, not billed; checking client-side saves the round trip). Pages are 1–100: the server rejects a `page` above 100 with a 400.
31
+ - `bin/smoke.rb` live smoke script: asserts result shapes (not just the absence of exceptions), runs page tools with `js: false` on datacenter proxies to keep the sweep cheap, and redacts the API key from failure output.
11
32
 
12
33
  ### Fixed
13
34
 
data/README.md CHANGED
@@ -26,8 +26,8 @@ Requires Ruby 3.1+.
26
26
 
27
27
  ## Quick start
28
28
 
29
- [Sign up](https://webscraping.ai/auth/sign_up) to get an API key — the free
30
- trial includes 2,000 credits, no credit card required. Your key lives in the
29
+ [Sign up](https://webscraping.ai/auth/sign_up) to get an API key — a free
30
+ trial, no credit card required. Your key lives in the
31
31
  [dashboard](https://webscraping.ai/dashboard).
32
32
 
33
33
  ```ruby
@@ -64,6 +64,16 @@ data = client.fields(
64
64
  results = client.serp(q: "coffee machines", gl: "us", hl: "en", page: 1)
65
65
  results["organic_results"].first["link"]
66
66
 
67
+ # Two pages of results searched from Austin, as one de-duplicated list
68
+ results = client.serp(q: "coffee machines", location: "Austin, TX", pages: 2)
69
+
70
+ # Location names for serp's location: (free)
71
+ client.serp_locations("austin").first["canonical_name"] # => "Austin,Texas,United States"
72
+
73
+ # Structured data for a page on a supported site (YouTube, TikTok, X, LinkedIn, Instagram, Reddit, ...)
74
+ video = client.data("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
75
+ video["data"]["title"]
76
+
67
77
  # Check your account quota
68
78
  info = client.account
69
79
  # => { "remaining_api_calls" => 200_000, "resets_at" => 1_617_073_667, "remaining_concurrency" => 100 }
@@ -128,15 +138,28 @@ Returns: `String` for HTML/text responses, `Hash`/`Array` for JSON responses.
128
138
  ### SERP (`#serp`)
129
139
 
130
140
  `#serp` is query-shaped rather than URL-shaped, so none of the page-fetch options above apply.
131
- It returns the parsed search results as a `Hash`. Flat 15 credits per search; failed searches are not charged.
141
+ It returns the parsed search results as a `Hash`. Billed per results page returned (see [pricing](https://webscraping.ai/docs#serp)); failed searches are not charged.
142
+
143
+ Every search runs from one fixed location: `location`, `lat` + `lon`, or `uule`, otherwise the capital of
144
+ the `gl` country (for the few countries outside the location list, Google places the search by the exit IP),
145
+ so repeats and later pages are consistent. `gl` defaults to the location's country.
146
+ `pages` (1–5) fetches consecutive pages, starting at `page`, as one de-duplicated list, billed per page returned.
132
147
 
133
148
  | Option | Type | Default | Description |
134
149
  | --- | --- | --- | --- |
135
150
  | `q` | `String` | — | Search query (required; blank or non-String raises `ArgumentError`) |
136
151
  | `engine` | `String` | `"google"` | Search engine; currently only `google` |
137
- | `gl` | `String` | `"us"` | Two-letter country code for the search |
152
+ | `gl` | `String` | `"us"` | Two-letter country code for the search. Defaults to the country of `location`, `lat`/`lon` or `uule`, otherwise `us` |
138
153
  | `hl` | `String` | `"en"` | Two-letter language code for the results |
139
154
  | `page` | `Integer` | `1` | Results page number (10 results per page). Must be an `Integer` >= 1, otherwise `ArgumentError`; the server rejects values above 100 with a 400 (not billed) |
155
+ | `pages` | `Integer` | `1` | Number of consecutive results pages to fetch in one call, starting at `page`, from 1 to 5. Duplicates across pages are removed, `position` counts through the combined list and each result gets a `page` key. Must be an `Integer` >= 1, otherwise `ArgumentError` |
156
+ | `location` | `String` | — | Where the search runs from: a name such as `"Austin,Texas,United States"` or `"Austin, TX"`, or an `id` from `#serp_locations`. Large cities also match local names, e.g. `"Москва"`. Sent as-is; an unknown name returns a 400 (not billed). Cannot be combined with `uule`, `lat` or `lon` |
157
+ | `uule` | `String` | — | Google-encoded location (`w+CAIQICI…` or `a+…`). Cannot be combined with `location`, `lat` or `lon` |
158
+ | `lat` | `Float` | — | Latitude of the search origin in decimal degrees (-90 to 90). Requires `lon`. Must be a finite number, otherwise `ArgumentError`; sent as a plain decimal rounded to 7 places |
159
+ | `lon` | `Float` | — | Longitude of the search origin in decimal degrees (-180 to 180). Requires `lat`. Same rules as `lat` |
160
+ | `radius` | `Integer` | — | Radius in meters around `location` or `lat`/`lon`, from 1 to 1000. Must be an `Integer` >= 1, otherwise `ArgumentError` |
161
+
162
+ Ranges and which location options can be combined are checked by the server (a 400, not billed).
140
163
 
141
164
  ```ruby
142
165
  results = client.serp(q: "coffee machines", gl: "gb", page: 2)
@@ -145,13 +168,80 @@ results["organic_results"].each do |r|
145
168
  puts "#{r["position"]}. #{r["title"]} — #{r["link"]}"
146
169
  end
147
170
  results["pagination"] # => { "current" => 2, "next" => 3 }
171
+
172
+ results = client.serp(q: "coffee machines", location: "Austin, TX", pages: 2)
173
+ results["search_parameters"]["location_used"] # => "Austin,Texas,United States"
174
+ results["organic_results"].last["page"] # => 2 (position counts through both pages)
175
+ results["pagination"]["pages_returned"] # => 2
148
176
  ```
149
177
 
150
- Response keys: `search_parameters` (`engine`, `q`, `gl`, `hl`, `page`), `search_information`
178
+ Response keys: `search_parameters` (`engine`, `q`, `gl`, `hl`, `page`; `location_used`, `lat` and `lon`, usually
179
+ present, which are the `gl` country's capital when no location is given; `location_requested` and `radius` when given;
180
+ `pages` only above 1), `search_information`
151
181
  (`query_displayed`, `organic_results_state`, optional `showing_results_for` and `total_results`),
152
- `organic_results` (`position` — 1-based within the page — `title`, `link`, `domain`, `displayed_link`,
153
- optional `snippet` and `date`), optional `related_searches` (`query`), and `pagination` (`current`, optional `next`).
154
- Optional keys are absent when Google does not show them.
182
+ `organic_results` (`position` — 1-based within the page, or through the combined list with `pages` above 1 —
183
+ `title`, `link`, `domain`, `displayed_link`, optional `snippet` and `date`, and `page` with `pages` above 1),
184
+ optional `related_searches` (`query`), and `pagination` (`current`, optional `next`, and `pages_returned` with
185
+ `pages` above 1). Optional keys are absent when Google does not show them.
186
+
187
+ ### SERP locations (`#serp_locations`)
188
+
189
+ `#serp_locations(q = nil, limit: nil)` looks up names for `#serp`'s `location:` option. It is free and the
190
+ endpoint needs no API key (the client still sends yours). It returns an `Array` of up to 10 location `Hash`es,
191
+ most populous first. `q` is part of an English location name (without it, the most populous locations are
192
+ returned); `limit` must be an `Integer` >= 1 (the server allows up to 10).
193
+
194
+ ```ruby
195
+ locations = client.serp_locations("austin", limit: 3)
196
+ locations.first
197
+ # => { "id" => "1026201", "google_id" => 1026201, "google_parent_id" => 21176, "name" => "Austin",
198
+ # "canonical_name" => "Austin,Texas,United States", "country_code" => "US", "target_type" => "City",
199
+ # "reach" => 974447, "gps" => [-97.7431, 30.2672] }
200
+
201
+ client.serp(q: "coffee machines", location: locations.first["canonical_name"]) # or its "id"
202
+ ```
203
+
204
+ `gps` is `[longitude, latitude]`, `target_type` is an open set of strings (e.g. `City`, `State`, `Country`),
205
+ and `google_parent_id` is `nil` for countries.
206
+
207
+ ### Structured data (`#data`)
208
+
209
+ `#data(url, country: nil, transcript: nil, transcript_language: nil, **params)` returns structured JSON
210
+ for a public page on a supported site as a `Hash`. Pass the page's normal URL; the site (`provider`) and
211
+ page kind (`type`) are detected from it. Priced per site (see [pricing](https://webscraping.ai/docs#data)),
212
+ including pages that parse empty (`parse_status` `"parse_failed"`) or no longer exist (`"not_found"`);
213
+ unsupported URLs and failed fetches are not charged.
214
+ None of the page-fetch options above apply.
215
+
216
+ Supported sites today include, for example, YouTube (video/channel/playlist), TikTok (video/profile),
217
+ X/Twitter (tweet/profile), LinkedIn (company/job/profile), Instagram (post/reel/profile) and Reddit
218
+ (post/subreddit/user). **More sites are added server-side**, and they work with this gem without an
219
+ upgrade: the client never checks the URL's site. An unsupported URL or page type returns a 400 that is
220
+ not charged (`WebScrapingAI::BadRequestError`). Its message lists what is supported.
221
+
222
+ | Option | Type | Default | Description |
223
+ | --- | --- | --- | --- |
224
+ | `url` | `String` | — | Page URL (required, positional; blank or non-String raises `ArgumentError`) |
225
+ | `country` | `String` | `"us"` | Two-letter country code of the proxy used to fetch the page, `us` by default |
226
+ | `transcript` | `Boolean` | `false` | YouTube videos only. Also fetch the video's transcript into `data.transcript`. It's null when no matching captions are available. If the transcript fetch itself fails, the whole request fails with a 500 and is not charged |
227
+ | `transcript_language` | `String` | — | Caption language to pick, e.g. `en` or `de`. Without it, English is preferred, then the first available track. If the video has no captions in that language, `data.transcript` is null |
228
+ | `**params` | `String`, `Integer`, `Float`, boolean | — | Extra query params sent as-is, for provider-specific params added later (`nil` omits one). `api_key`, `url`, `country`, `transcript` and `transcript_language` raise `ArgumentError`; use the named options for the last three |
229
+
230
+ ```ruby
231
+ result = client.data("https://www.youtube.com/watch?v=dQw4w9WgXcQ", transcript: true)
232
+ result["request_parameters"] # => { "url" => "...", "provider" => "youtube", "type" => "video" }
233
+ result["parse_status"] # => "ok" (or "parse_failed" / "not_found")
234
+ result["data"]["title"] # shape depends on provider and type; may be nil
235
+
236
+ begin
237
+ client.data("https://example.com/")
238
+ rescue WebScrapingAI::BadRequestError => e
239
+ e.message # => "Unsupported URL for /data. Supported sites: youtube, tiktok, ..."
240
+ end
241
+ ```
242
+
243
+ `provider`, `type` and `parse_status` are open sets of strings, and `data` is the decoded JSON as-is
244
+ (no per-site classes), so new sites and fields show up without a gem release.
155
245
 
156
246
  ## Error handling
157
247
 
@@ -191,7 +281,7 @@ bundle exec rubocop
191
281
 
192
282
  ## Smoke testing
193
283
 
194
- `bin/smoke.rb` hits every endpoint once against the live API, loading the gem from `lib/` so it tests the working tree. It is not part of the spec suite and costs ~32 credits per run: the four page calls run with `js: false` and `proxy: "datacenter"` (1 credit each), `question` and `fields` cost 6 each, and the SERP call is 15. Each case checks the result shape as well as exceptions (e.g. SERP must return organic results for the right query, `selected_multiple` must match something), and failure messages redact the API key.
284
+ `bin/smoke.rb` hits every endpoint once against the live API, loading the gem from `lib/` so it tests the working tree. It is not part of the spec suite and uses real credits on every run (see [pricing](https://webscraping.ai/docs)): the four page calls run with `js: false` and `proxy: "datacenter"` to keep them cheap, there are two AI calls (`question`, `fields`), two SERP calls (one plain, one two-page search from Austin, billed per page returned) and one `/data` call (a YouTube video). `serp_locations` is free. A second `/data` call on `https://example.com/` must come back as the server's free 400, proving there is no client-side site filter. Each case checks the result shape as well as exceptions (e.g. SERP must return organic results for the right query, the Austin SERP must report an Austin `location_used`, `serp_locations` must find Austin, `/data` must parse a title, `selected_multiple` must match something), and failure messages redact the API key.
195
285
 
196
286
  ```bash
197
287
  WEBSCRAPING_AI_API_KEY=... bundle exec rake smoke
@@ -69,15 +69,71 @@ module WebScrapingAI
69
69
 
70
70
  # GET /serp — returns parsed search engine results for `q` as a Hash
71
71
  # (search_parameters, search_information, organic_results, related_searches, pagination).
72
- # Query-shaped: none of the page-fetch options apply. Flat 15 credits per search.
72
+ # Query-shaped: none of the page-fetch options apply. Billed per results page returned
73
+ # (see https://webscraping.ai/docs#serp); failed searches are not charged.
73
74
  # `q` must be a non-blank String (sent as-is, untrimmed). `page`, when given, must be an
74
75
  # Integer >= 1; the server rejects values above 100 with a 400 (not billed).
75
- def serp(q:, engine: nil, gl: nil, hl: nil, page: nil)
76
+ #
77
+ # Every search runs from one fixed location: `location` (a name such as "Austin, TX" or
78
+ # "Austin,Texas,United States", or an id from #serp_locations), `lat` + `lon`, or `uule`, otherwise
79
+ # the capital of the `gl` country, so repeats and later pages are consistent. `gl` defaults to the
80
+ # location's country. `pages` (1-5) fetches consecutive pages starting at `page` as one de-duplicated
81
+ # list: `position` counts through the combined list and each result gets a `page` key.
82
+ #
83
+ # `pages` and `radius` (meters, 1-1000), when given, must be Integers >= 1; `lat` and `lon` must be
84
+ # finite numbers and are sent as plain decimals. Ranges and which location params can be combined are
85
+ # checked by the server (a 400, not billed).
86
+ def serp(q:, engine: nil, gl: nil, hl: nil, page: nil, pages: nil, location: nil, uule: nil, lat: nil,
87
+ lon: nil, radius: nil)
76
88
  raise ArgumentError, "q is required" if q.nil? || (q.is_a?(String) && q.strip.empty?)
77
89
  raise ArgumentError, "q must be a String" unless q.is_a?(String)
78
- raise ArgumentError, "page must be an Integer >= 1" unless page.nil? || (page.is_a?(Integer) && page >= 1)
79
90
 
80
- get("/serp", q: q, engine: engine, gl: gl, hl: hl, page: page)
91
+ check_positive_integer(:page, page)
92
+ check_positive_integer(:pages, pages)
93
+ check_positive_integer(:radius, radius)
94
+
95
+ get("/serp", q: q, engine: engine, gl: gl, hl: hl, page: page, pages: pages, location: location,
96
+ uule: uule, lat: coordinate(:lat, lat), lon: coordinate(:lon, lon), radius: radius)
97
+ end
98
+
99
+ # GET /serp/locations — returns an Array of location Hashes matching `q` (part of an English
100
+ # location name), most populous first: id, google_id, google_parent_id (nil for countries), name,
101
+ # canonical_name, country_code, target_type, reach and gps ([longitude, latitude]). Pass
102
+ # `canonical_name` (or `id`) as #serp's `location:`. Without `q`, the most populous locations are
103
+ # returned. `limit`, when given, must be an Integer >= 1; the server allows up to 10. Free.
104
+ def serp_locations(q = nil, limit: nil)
105
+ check_positive_integer(:limit, limit)
106
+
107
+ get("/serp/locations", q: q, limit: limit)
108
+ end
109
+
110
+ # GET /data — returns structured JSON for a page on a supported site as a Hash
111
+ # (request_parameters: url/provider/type, parse_status, data). Priced per site
112
+ # (see https://webscraping.ai/docs#data); unsupported URLs and failed fetches are not charged.
113
+ #
114
+ # The client never checks which site `url` belongs to: supported sites (e.g. YouTube, TikTok,
115
+ # X/Twitter, LinkedIn, Instagram, Reddit) are added server-side. An unsupported URL or page type
116
+ # returns a 400 that is not charged (BadRequestError); its message lists what is supported.
117
+ # `provider`, `type` and `parse_status` are open sets of strings; `data` may be nil.
118
+ #
119
+ # `country`: two-letter country code of the proxy used to fetch the page, `us` by default.
120
+ # `transcript`: YouTube videos only. Also fetch the video's transcript into `data.transcript`.
121
+ # It's null when no matching captions are available. If the transcript fetch itself fails, the
122
+ # whole request fails with a 500 and is not charged.
123
+ # `transcript_language`: caption language to pick, e.g. `en` or `de`. Without it, English is
124
+ # preferred, then the first available track. If the video has no captions in that language,
125
+ # `data.transcript` is null.
126
+ #
127
+ # Any other keyword arguments are sent as-is as extra query params (for provider-specific params
128
+ # added later). Values must be String, Integer, Float or boolean (nil omits the param); the names
129
+ # `api_key`, `url`, `country`, `transcript` and `transcript_language` raise ArgumentError.
130
+ # None of the page-fetch options apply.
131
+ def data(url, country: nil, transcript: nil, transcript_language: nil, **params)
132
+ raise ArgumentError, "url is required" if url.nil? || (url.is_a?(String) && url.strip.empty?)
133
+ raise ArgumentError, "url must be a String" unless url.is_a?(String)
134
+
135
+ named = { country: country, transcript: transcript, transcript_language: transcript_language }.compact
136
+ get("/data", url: url, **data_extra_params(params), **named)
81
137
  end
82
138
 
83
139
  def inspect
@@ -91,6 +147,73 @@ module WebScrapingAI
91
147
 
92
148
  private
93
149
 
150
+ DATA_RESERVED_PARAMS = %w[api_key url].freeze
151
+ DATA_TYPED_PARAMS = %w[country transcript transcript_language].freeze
152
+ private_constant :DATA_RESERVED_PARAMS, :DATA_TYPED_PARAMS
153
+
154
+ # Extra /data query params are passed through untouched (same encoder, so `&`/`=` are escaped),
155
+ # except that they can't override the credentials or the target URL, and can't repeat a typed
156
+ # param (a string key like "country" would otherwise collide with `country:`).
157
+ def data_extra_params(params)
158
+ params.each_with_object({}) do |(key, value), extra|
159
+ name = key.to_s
160
+ raise ArgumentError, "#{name} can't be passed as an extra /data param" if DATA_RESERVED_PARAMS.include?(name)
161
+ if DATA_TYPED_PARAMS.include?(name)
162
+ raise ArgumentError, "#{name} can't be passed as an extra /data param; use the #{name}: option"
163
+ end
164
+
165
+ extra[name.to_sym] = data_extra_value(name, value)
166
+ end
167
+ end
168
+
169
+ def data_extra_value(name, value)
170
+ case value
171
+ when nil, String, Integer, true, false then value
172
+ when Float
173
+ raise ArgumentError, "extra /data param #{name} must be a finite number" unless value.finite?
174
+
175
+ plain_float(value)
176
+ else
177
+ raise ArgumentError, "extra /data param #{name} must be a String, Integer, Float or boolean"
178
+ end
179
+ end
180
+
181
+ def check_positive_integer(name, value)
182
+ return if value.nil? || (value.is_a?(Integer) && value >= 1)
183
+
184
+ raise ArgumentError, "#{name} must be an Integer >= 1"
185
+ end
186
+
187
+ # The server only accepts plain decimals (`-?\d+(\.\d+)?`) for lat/lon, so never send Ruby's
188
+ # exponent notation (1.0e-07): round to 7 fractional digits and trim trailing zeros.
189
+ def coordinate(name, value)
190
+ return nil if value.nil?
191
+ raise ArgumentError, "#{name} must be a finite number" unless value.is_a?(Numeric) && value.real? && value.finite?
192
+
193
+ text = format("%.7f", value).sub(/0+\z/, "").delete_suffix(".")
194
+ # BigDecimal("1e400") is finite but formats as "Inf".
195
+ raise ArgumentError, "#{name} must be a finite number" unless text.match?(/\A-?\d+(\.\d+)?\z/)
196
+
197
+ text == "-0" ? "0" : text
198
+ end
199
+
200
+ # Float#to_s switches to exponent notation (1.0e+20, 1.5e-07); send plain decimals instead.
201
+ def plain_float(value)
202
+ text = value.to_s
203
+ return text unless text.include?("e")
204
+
205
+ mantissa, exponent = text.split("e")
206
+ fraction_digits = mantissa.split(".")[1].to_s.sub(/0+\z/, "").length
207
+ format("%.#{[fraction_digits - exponent.to_i, 0].max}f", value)
208
+ end
209
+
210
+ def redact(text)
211
+ key = configuration.api_key.to_s
212
+ text = text.to_s
213
+ text = text.gsub(key, "[FILTERED]") unless key.empty?
214
+ text.gsub(/api_key=[^&\s"']*/, "api_key=[FILTERED]")
215
+ end
216
+
94
217
  def connection
95
218
  @connection ||= Faraday.new(url: configuration.base_url) do |conn|
96
219
  conn.options.timeout = configuration.timeout
@@ -102,15 +225,17 @@ module WebScrapingAI
102
225
  end
103
226
  end
104
227
 
228
+ # Transport errors are re-raised with `cause: nil` and a redacted message: the Faraday error (and
229
+ # anything it wraps) may carry the request URL, which contains the API key.
105
230
  def get(path, **params)
106
231
  response = connection.get(path) do |req|
107
232
  req.params = params.merge(api_key: configuration.api_key)
108
233
  end
109
234
  handle_response(response)
110
235
  rescue Faraday::TimeoutError => e
111
- raise TimeoutError, e.message
236
+ raise TimeoutError.new(redact(e.message)), cause: nil
112
237
  rescue Faraday::ConnectionFailed => e
113
- raise ConnectionError, e.message
238
+ raise ConnectionError.new(redact(e.message)), cause: nil
114
239
  end
115
240
 
116
241
  def handle_response(response)
@@ -1,3 +1,3 @@
1
1
  module WebScrapingAI
2
- VERSION = "4.1.0".freeze
2
+ VERSION = "4.3.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: webscraping_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.1.0
4
+ version: 4.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - WebScraping.AI