enconvert 0.0.1
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/LICENSE +21 -0
- data/README.md +308 -0
- data/enconvert.gemspec +29 -0
- data/lib/enconvert/client.rb +419 -0
- data/lib/enconvert/errors.rb +41 -0
- data/lib/enconvert/formats.rb +195 -0
- data/lib/enconvert/internal.rb +153 -0
- data/lib/enconvert/results.rb +31 -0
- data/lib/enconvert/v2.rb +688 -0
- data/lib/enconvert/v2_results.rb +130 -0
- data/lib/enconvert/version.rb +5 -0
- data/lib/enconvert.rb +30 -0
- metadata +63 -0
data/lib/enconvert/v2.rb
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "internal"
|
|
7
|
+
require_relative "v2_results"
|
|
8
|
+
|
|
9
|
+
# V2 API namespace, reached as `client.v2`.
|
|
10
|
+
#
|
|
11
|
+
# One method per V2 endpoint (21 total across six groups: perceive,
|
|
12
|
+
# discover, lookup, distill, ingest, watch). Options are passed as
|
|
13
|
+
# snake_case keyword arguments and serialized to the API's snake_case wire
|
|
14
|
+
# format (a straight pass-through, since Ruby's own idiom already is
|
|
15
|
+
# snake_case); responses are mapped back onto snake_case Struct readers.
|
|
16
|
+
# User-data payloads (schemas, extracted data, tracked fields, diff
|
|
17
|
+
# changes) pass through untouched as plain Ruby Hashes.
|
|
18
|
+
#
|
|
19
|
+
# All V2 endpoints require a private API key (public keys are rejected)
|
|
20
|
+
# and are plan-gated: a disabled feature or exhausted monthly quota
|
|
21
|
+
# raises QuotaError (HTTP 402).
|
|
22
|
+
module Enconvert
|
|
23
|
+
class V2
|
|
24
|
+
include Internal
|
|
25
|
+
|
|
26
|
+
def initialize(transport)
|
|
27
|
+
@transport = transport
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Perceive — render a URL into agent-ready artifacts
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
# Render one URL into the requested outputs (markdown, screenshots, PDF,
|
|
35
|
+
# links, structured data, ...). Synchronous: returns the completed
|
|
36
|
+
# operation with 15-minute signed artifact URLs.
|
|
37
|
+
#
|
|
38
|
+
# opts accepts: outputs, extract, schema, wait_for, wait_timeout_ms,
|
|
39
|
+
# js_code, viewport, headers, cookies, auth, proxy_url, geolocation,
|
|
40
|
+
# action_chain, cache_mode, pdf_options, block_resources,
|
|
41
|
+
# respect_robots, mobile.
|
|
42
|
+
def perceive(url, **opts)
|
|
43
|
+
body = serialize_perceive_options(opts)
|
|
44
|
+
body[:url] = url
|
|
45
|
+
to_perceive_result(post("/v2/perceive", body))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Re-fetch a perceive operation by id (`per_...`). Artifact URLs are
|
|
49
|
+
# freshly re-signed on every call.
|
|
50
|
+
def get_perceive_operation(operation_id)
|
|
51
|
+
to_perceive_result(get("/v2/perceive/#{encode_path(operation_id)}"))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Perceive up to 1000 URLs with one shared options block. Small batches
|
|
55
|
+
# run inline (completed result); larger ones return status "queued" —
|
|
56
|
+
# poll get_perceive_batch with the job_id.
|
|
57
|
+
def perceive_batch(urls, output_mode: nil, **render_opts)
|
|
58
|
+
body = { urls: urls, options: serialize_perceive_options(render_opts) }
|
|
59
|
+
body[:output_mode] = output_mode unless output_mode.nil?
|
|
60
|
+
to_perceive_batch_result(post("/v2/perceive/batch", body))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Poll a perceive batch by job_id. Items fill in as URLs complete.
|
|
64
|
+
def get_perceive_batch(job_id)
|
|
65
|
+
to_perceive_batch_result(get("/v2/perceive/batch/#{encode_path(job_id)}"))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Discover — enumerate a site's URLs without rendering
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
# List a site's URLs via sitemap, HTTP crawl, or both. No browser
|
|
73
|
+
# rendering — fast and does not consume perceive quota.
|
|
74
|
+
#
|
|
75
|
+
# opts accepts: mode, max_urls, max_depth, include_patterns,
|
|
76
|
+
# exclude_patterns, same_domain_only, respect_robots.
|
|
77
|
+
def discover(url, **opts)
|
|
78
|
+
body = { url: url }
|
|
79
|
+
body[:mode] = opts[:mode] if opts.key?(:mode)
|
|
80
|
+
body[:max_urls] = opts[:max_urls] if opts.key?(:max_urls)
|
|
81
|
+
body[:max_depth] = opts[:max_depth] if opts.key?(:max_depth)
|
|
82
|
+
body[:include_patterns] = opts[:include_patterns] if opts.key?(:include_patterns)
|
|
83
|
+
body[:exclude_patterns] = opts[:exclude_patterns] if opts.key?(:exclude_patterns)
|
|
84
|
+
body[:same_domain_only] = opts[:same_domain_only] if opts.key?(:same_domain_only)
|
|
85
|
+
body[:respect_robots] = opts[:respect_robots] if opts.key?(:respect_robots)
|
|
86
|
+
to_discover_result(post("/v2/discover", body))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Lookup — web search with optional auto-perceive
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
# Run a categorized web search. With perceive_top > 0, the top-N result
|
|
94
|
+
# URLs are auto-perceived (each consumes one perceive-quota unit) and
|
|
95
|
+
# carry their full PerceiveResult inline.
|
|
96
|
+
#
|
|
97
|
+
# opts accepts: category, country, locale, time_filter, num_results,
|
|
98
|
+
# page, location, autocorrect, perceive_top.
|
|
99
|
+
def lookup(query, **opts)
|
|
100
|
+
body = { query: query }
|
|
101
|
+
body[:category] = opts[:category] if opts.key?(:category)
|
|
102
|
+
body[:country] = opts[:country] if opts.key?(:country)
|
|
103
|
+
body[:locale] = opts[:locale] if opts.key?(:locale)
|
|
104
|
+
body[:time_filter] = opts[:time_filter] if opts.key?(:time_filter)
|
|
105
|
+
body[:num_results] = opts[:num_results] if opts.key?(:num_results)
|
|
106
|
+
body[:page] = opts[:page] if opts.key?(:page)
|
|
107
|
+
body[:location] = opts[:location] if opts.key?(:location)
|
|
108
|
+
body[:autocorrect] = opts[:autocorrect] if opts.key?(:autocorrect)
|
|
109
|
+
body[:perceive_top] = opts[:perceive_top] if opts.key?(:perceive_top)
|
|
110
|
+
to_lookup_result(post("/v2/lookup", body))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# Distill — schema-driven structured extraction
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
# Extract structured data matching `schema` from explicit URLs or from
|
|
118
|
+
# a discovered site. An optional css_schema answers fields for free;
|
|
119
|
+
# anything it misses escalates to the LLM tier (plan-gated).
|
|
120
|
+
#
|
|
121
|
+
# Provide exactly one of `urls` (non-empty Array) or `discover_from`
|
|
122
|
+
# (Hash with :url, :mode, :max_pages). `schema` is required.
|
|
123
|
+
def distill(urls: nil, discover_from: nil, schema: nil, css_schema: nil, wait_for: nil,
|
|
124
|
+
wait_timeout_ms: nil, headers: nil, cookies: nil, respect_robots: nil)
|
|
125
|
+
has_urls = urls.is_a?(Array) && !urls.empty?
|
|
126
|
+
has_discover = !discover_from.nil?
|
|
127
|
+
raise Enconvert::Error, "distill: provide exactly one of 'urls' or 'discover_from'" if has_urls == has_discover
|
|
128
|
+
raise Enconvert::Error, "distill: 'schema' is required and must be a Hash" unless schema.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
body = { schema: schema }
|
|
131
|
+
body[:urls] = urls if has_urls
|
|
132
|
+
if discover_from
|
|
133
|
+
df = { url: discover_from[:url] }
|
|
134
|
+
df[:mode] = discover_from[:mode] if discover_from.key?(:mode)
|
|
135
|
+
df[:max_pages] = discover_from[:max_pages] if discover_from.key?(:max_pages)
|
|
136
|
+
body[:discover_from] = df
|
|
137
|
+
end
|
|
138
|
+
body[:css_schema] = serialize_css_schema(css_schema) unless css_schema.nil?
|
|
139
|
+
body[:wait_for] = wait_for unless wait_for.nil?
|
|
140
|
+
body[:wait_timeout_ms] = wait_timeout_ms unless wait_timeout_ms.nil?
|
|
141
|
+
body[:headers] = headers unless headers.nil?
|
|
142
|
+
body[:cookies] = cookies unless cookies.nil?
|
|
143
|
+
body[:respect_robots] = respect_robots unless respect_robots.nil?
|
|
144
|
+
to_distill_result(post("/v2/distill", body))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Ingest — site to RAG-ready JSONL chunks (always async)
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
# Start an ingest job: turn explicit URLs or a discovered site into
|
|
152
|
+
# chunked, RAG-ready JSONL. Always asynchronous — returns the queued
|
|
153
|
+
# job; poll get_ingest_job or configure webhook_url for completion.
|
|
154
|
+
#
|
|
155
|
+
# `mode` defaults to "urls" for validation purposes but is only sent on
|
|
156
|
+
# the wire when explicitly given (the API defaults it itself).
|
|
157
|
+
def ingest(mode: nil, url: nil, urls: nil, max_pages: nil, max_depth: nil, same_domain_only: nil,
|
|
158
|
+
include_patterns: nil, exclude_patterns: nil, respect_robots: nil, wait_for: nil,
|
|
159
|
+
wait_timeout_ms: nil, chunk: nil, webhook_url: nil)
|
|
160
|
+
effective_mode = mode || "urls"
|
|
161
|
+
if effective_mode == "urls"
|
|
162
|
+
raise Enconvert::Error, "ingest: mode 'urls' requires a non-empty 'urls' list" if urls.nil? || urls.empty?
|
|
163
|
+
raise Enconvert::Error, "ingest: mode 'urls' does not accept 'url'" unless url.nil?
|
|
164
|
+
else
|
|
165
|
+
raise Enconvert::Error, "ingest: mode '#{effective_mode}' requires a seed 'url'" if url.nil?
|
|
166
|
+
raise Enconvert::Error, "ingest: mode '#{effective_mode}' does not accept 'urls'" unless urls.nil?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
body = {}
|
|
170
|
+
body[:mode] = mode unless mode.nil?
|
|
171
|
+
body[:url] = url unless url.nil?
|
|
172
|
+
body[:urls] = urls unless urls.nil?
|
|
173
|
+
body[:max_pages] = max_pages unless max_pages.nil?
|
|
174
|
+
body[:max_depth] = max_depth unless max_depth.nil?
|
|
175
|
+
body[:same_domain_only] = same_domain_only unless same_domain_only.nil?
|
|
176
|
+
body[:include_patterns] = include_patterns unless include_patterns.nil?
|
|
177
|
+
body[:exclude_patterns] = exclude_patterns unless exclude_patterns.nil?
|
|
178
|
+
body[:respect_robots] = respect_robots unless respect_robots.nil?
|
|
179
|
+
body[:wait_for] = wait_for unless wait_for.nil?
|
|
180
|
+
body[:wait_timeout_ms] = wait_timeout_ms unless wait_timeout_ms.nil?
|
|
181
|
+
if chunk
|
|
182
|
+
c = {}
|
|
183
|
+
c[:max_words] = chunk[:max_words] if chunk.key?(:max_words)
|
|
184
|
+
c[:sentence_overlap] = chunk[:sentence_overlap] if chunk.key?(:sentence_overlap)
|
|
185
|
+
body[:chunk] = c
|
|
186
|
+
end
|
|
187
|
+
body[:webhook_url] = webhook_url unless webhook_url.nil?
|
|
188
|
+
to_ingest_job(post("/v2/ingest", body))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Ingest one or more uploaded FILES into RAG-ready JSONL chunks — the
|
|
192
|
+
# file counterpart of ingest(), sharing the same job lifecycle (mode
|
|
193
|
+
# "files"). PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT/MD and
|
|
194
|
+
# legacy/ODF office are accepted. Always asynchronous; poll
|
|
195
|
+
# get_ingest_job or configure a webhook.
|
|
196
|
+
#
|
|
197
|
+
# `files` accepts the same file-input union as convert_image (path
|
|
198
|
+
# string, IO-like/readable object, or { data:, filename: }); at least
|
|
199
|
+
# one file is required.
|
|
200
|
+
def ingest_files(files, chunk: nil, webhook_url: nil)
|
|
201
|
+
unless files.is_a?(Array) && !files.empty?
|
|
202
|
+
raise Enconvert::Error, "ingest_files: provide at least one file"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
fields = files.map do |file|
|
|
206
|
+
part = to_file_part(file)
|
|
207
|
+
{ name: "files", value: part.bytes, filename: part.filename, content_type: part.content_type }
|
|
208
|
+
end
|
|
209
|
+
if chunk
|
|
210
|
+
fields << { name: "max_words", value: chunk[:max_words] } if chunk.key?(:max_words)
|
|
211
|
+
fields << { name: "sentence_overlap", value: chunk[:sentence_overlap] } if chunk.key?(:sentence_overlap)
|
|
212
|
+
end
|
|
213
|
+
fields << { name: "webhook_url", value: webhook_url } unless webhook_url.nil?
|
|
214
|
+
|
|
215
|
+
body, boundary = build_multipart(fields)
|
|
216
|
+
resp = @transport.request(
|
|
217
|
+
:post, "/v2/ingest/files",
|
|
218
|
+
headers: { "Content-Type" => "multipart/form-data; boundary=#{boundary}" },
|
|
219
|
+
body: body
|
|
220
|
+
)
|
|
221
|
+
raise_for_status(resp)
|
|
222
|
+
to_ingest_job(JSON.parse(resp.body))
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# List ingest jobs, newest first.
|
|
226
|
+
def list_ingest_jobs(skip: nil, limit: nil)
|
|
227
|
+
d = get("/v2/ingest#{list_query(skip, limit)}")
|
|
228
|
+
jobs = d["jobs"].is_a?(Array) ? d["jobs"] : []
|
|
229
|
+
IngestJobList.new(
|
|
230
|
+
jobs: jobs.map { |j| to_ingest_job_summary(j) },
|
|
231
|
+
skip: num(d["skip"]),
|
|
232
|
+
limit: num(d["limit"], 20),
|
|
233
|
+
has_more: d["has_more"] == true
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Get one ingest job by id (`ing_...`).
|
|
238
|
+
def get_ingest_job(job_id)
|
|
239
|
+
to_ingest_job(get("/v2/ingest/#{encode_path(job_id)}"))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Cancel a queued/processing ingest job. Idempotent: canceling an
|
|
243
|
+
# already-terminal job returns it unchanged.
|
|
244
|
+
def cancel_ingest_job(job_id)
|
|
245
|
+
to_ingest_job(delete_request("/v2/ingest/#{encode_path(job_id)}"))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Re-deliver the completion webhook of a completed job (409 if the job
|
|
249
|
+
# is not completed, 400 if it has no webhook configured).
|
|
250
|
+
def retry_ingest_webhook(job_id)
|
|
251
|
+
d = post("/v2/ingest/#{encode_path(job_id)}/retry-webhook", nil)
|
|
252
|
+
WebhookRetryResult.new(
|
|
253
|
+
job_id: str(d["job_id"]),
|
|
254
|
+
delivered: d["delivered"] == true,
|
|
255
|
+
attempts: num(d["attempts"]),
|
|
256
|
+
status_code: opt_num(d["status_code"]),
|
|
257
|
+
detail: str(d["detail"])
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Get (creating on first call) the project's webhook signing secret and
|
|
262
|
+
# the header/scheme details needed to verify deliveries.
|
|
263
|
+
def get_webhook_secret
|
|
264
|
+
to_webhook_secret(get("/v2/ingest/webhook-secret"))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Rotate the webhook signing secret. Signatures made with the previous
|
|
268
|
+
# secret stop verifying immediately.
|
|
269
|
+
def rotate_webhook_secret
|
|
270
|
+
to_webhook_secret(post("/v2/ingest/webhook-secret/rotate", nil))
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
# Watch — recurring change monitoring
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
# Create a watcher that re-renders `url` on a fixed cadence (hourly
|
|
278
|
+
# floor) and notifies on changes via email and/or webhook.
|
|
279
|
+
def create_watcher(url, frequency_minutes: nil, diff_mode: nil, track_fields: nil,
|
|
280
|
+
webhook_url: nil, notify_email: nil)
|
|
281
|
+
body = { url: url }
|
|
282
|
+
body[:frequency_minutes] = frequency_minutes unless frequency_minutes.nil?
|
|
283
|
+
body[:diff_mode] = diff_mode unless diff_mode.nil?
|
|
284
|
+
body[:track_fields] = track_fields unless track_fields.nil?
|
|
285
|
+
body[:webhook_url] = webhook_url unless webhook_url.nil?
|
|
286
|
+
body[:notify_email] = notify_email unless notify_email.nil?
|
|
287
|
+
to_watcher(post("/v2/watch", body))
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# List watchers, newest first.
|
|
291
|
+
def list_watchers(skip: nil, limit: nil)
|
|
292
|
+
d = get("/v2/watch#{list_query(skip, limit)}")
|
|
293
|
+
watchers = d["watchers"].is_a?(Array) ? d["watchers"] : []
|
|
294
|
+
WatcherList.new(
|
|
295
|
+
watchers: watchers.map { |w| to_watcher_summary(w) },
|
|
296
|
+
skip: num(d["skip"]),
|
|
297
|
+
limit: num(d["limit"], 20),
|
|
298
|
+
has_more: d["has_more"] == true
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Get one watcher by id (`wat_...`). Deleted watchers read as 404.
|
|
303
|
+
def get_watcher(watcher_id)
|
|
304
|
+
to_watcher(get("/v2/watch/#{encode_path(watcher_id)}"))
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Page through a watcher's check history, newest first.
|
|
308
|
+
def get_watcher_snapshots(watcher_id, limit: nil)
|
|
309
|
+
query = limit.nil? ? "" : "?limit=#{limit}"
|
|
310
|
+
d = get("/v2/watch/#{encode_path(watcher_id)}/snapshots#{query}")
|
|
311
|
+
snapshots = d["snapshots"].is_a?(Array) ? d["snapshots"] : []
|
|
312
|
+
WatcherSnapshotList.new(
|
|
313
|
+
watcher_id: str(d["watcher_id"]),
|
|
314
|
+
snapshots: snapshots.map { |s| to_watcher_snapshot(s) },
|
|
315
|
+
limit: num(d["limit"], 20)
|
|
316
|
+
)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Update a watcher. At least one field is required. Set webhook_url to
|
|
320
|
+
# "" to clear the webhook; resuming a paused watcher re-checks the
|
|
321
|
+
# plan's watcher cap.
|
|
322
|
+
def update_watcher(watcher_id, frequency_minutes: nil, diff_mode: nil, track_fields: nil,
|
|
323
|
+
webhook_url: nil, notify_email: nil, status: nil)
|
|
324
|
+
body = {}
|
|
325
|
+
body[:frequency_minutes] = frequency_minutes unless frequency_minutes.nil?
|
|
326
|
+
body[:diff_mode] = diff_mode unless diff_mode.nil?
|
|
327
|
+
body[:track_fields] = track_fields unless track_fields.nil?
|
|
328
|
+
body[:webhook_url] = webhook_url unless webhook_url.nil?
|
|
329
|
+
body[:notify_email] = notify_email unless notify_email.nil?
|
|
330
|
+
body[:status] = status unless status.nil?
|
|
331
|
+
raise Enconvert::Error, "update_watcher: provide at least one field to update" if body.empty?
|
|
332
|
+
|
|
333
|
+
to_watcher(patch("/v2/watch/#{encode_path(watcher_id)}", body))
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Soft-delete a watcher (idempotent). Returns the tombstoned watcher
|
|
337
|
+
# with status "deleted".
|
|
338
|
+
def delete_watcher(watcher_id)
|
|
339
|
+
to_watcher(delete_request("/v2/watch/#{encode_path(watcher_id)}"))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
private
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
# HTTP helpers
|
|
346
|
+
# ------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def json_request(method, path, body: nil, headers: {})
|
|
349
|
+
resp = @transport.request(method, path, headers: headers, body: body)
|
|
350
|
+
raise_for_status(resp)
|
|
351
|
+
JSON.parse(resp.body)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def post(path, body)
|
|
355
|
+
json_request(:post, path,
|
|
356
|
+
body: body.nil? ? nil : JSON.generate(body),
|
|
357
|
+
headers: { "Content-Type" => "application/json" })
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def get(path)
|
|
361
|
+
json_request(:get, path)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def patch(path, body)
|
|
365
|
+
json_request(:patch, path, body: JSON.generate(body), headers: { "Content-Type" => "application/json" })
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def delete_request(path)
|
|
369
|
+
json_request(:delete, path)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def encode_path(value)
|
|
373
|
+
ERB::Util.url_encode(value.to_s)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def list_query(skip, limit)
|
|
377
|
+
params = []
|
|
378
|
+
params << "skip=#{skip}" unless skip.nil?
|
|
379
|
+
params << "limit=#{limit}" unless limit.nil?
|
|
380
|
+
params.empty? ? "" : "?#{params.join('&')}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# ------------------------------------------------------------------
|
|
384
|
+
# Request serializers
|
|
385
|
+
# ------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def serialize_perceive_options(o)
|
|
388
|
+
out = {}
|
|
389
|
+
out[:outputs] = o[:outputs] if o.key?(:outputs)
|
|
390
|
+
out[:extract] = o[:extract] if o.key?(:extract)
|
|
391
|
+
out[:schema] = o[:schema] if o.key?(:schema)
|
|
392
|
+
out[:wait_for] = o[:wait_for] if o.key?(:wait_for)
|
|
393
|
+
out[:wait_timeout_ms] = o[:wait_timeout_ms] if o.key?(:wait_timeout_ms)
|
|
394
|
+
out[:js_code] = o[:js_code] if o.key?(:js_code)
|
|
395
|
+
out[:viewport] = o[:viewport] if o.key?(:viewport)
|
|
396
|
+
out[:headers] = o[:headers] if o.key?(:headers)
|
|
397
|
+
out[:cookies] = o[:cookies] if o.key?(:cookies)
|
|
398
|
+
out[:auth] = o[:auth] if o.key?(:auth)
|
|
399
|
+
out[:proxy_url] = o[:proxy_url] if o.key?(:proxy_url)
|
|
400
|
+
out[:geolocation] = o[:geolocation] if o.key?(:geolocation)
|
|
401
|
+
out[:action_chain] = o[:action_chain] if o.key?(:action_chain)
|
|
402
|
+
out[:cache_mode] = o[:cache_mode] if o.key?(:cache_mode)
|
|
403
|
+
out[:pdf_options] = serialize_pdf_options(o[:pdf_options]) if o.key?(:pdf_options)
|
|
404
|
+
out[:block_resources] = o[:block_resources] if o.key?(:block_resources)
|
|
405
|
+
out[:respect_robots] = o[:respect_robots] if o.key?(:respect_robots)
|
|
406
|
+
out[:mobile] = o[:mobile] if o.key?(:mobile)
|
|
407
|
+
out
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def serialize_css_field(f)
|
|
411
|
+
out = { name: f[:name], type: f[:type] }
|
|
412
|
+
out[:selector] = f[:selector] if f.key?(:selector)
|
|
413
|
+
out[:attribute] = f[:attribute] if f.key?(:attribute)
|
|
414
|
+
out[:pattern] = f[:pattern] if f.key?(:pattern)
|
|
415
|
+
out[:default] = f[:default] if f.key?(:default)
|
|
416
|
+
out[:transform] = f[:transform] if f.key?(:transform)
|
|
417
|
+
out[:fields] = f[:fields].map { |nf| serialize_css_field(nf) } if f.key?(:fields)
|
|
418
|
+
out
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def serialize_css_schema(s)
|
|
422
|
+
out = { base_selector: s[:base_selector], fields: s[:fields].map { |f| serialize_css_field(f) } }
|
|
423
|
+
out[:name] = s[:name] if s.key?(:name)
|
|
424
|
+
out[:target_field] = s[:target_field] if s.key?(:target_field)
|
|
425
|
+
out
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
# Response mappers. Optional fields may be absent entirely
|
|
430
|
+
# (response_model_exclude_none) — every access is guarded.
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
def str(v, fallback = "")
|
|
434
|
+
v.is_a?(String) ? v : fallback
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def opt_str(v)
|
|
438
|
+
v.is_a?(String) ? v : nil
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def num(v, fallback = 0)
|
|
442
|
+
v.is_a?(Numeric) ? v : fallback
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def opt_num(v)
|
|
446
|
+
v.is_a?(Numeric) ? v : nil
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def str_arr(v)
|
|
450
|
+
v.is_a?(Array) ? v.select { |x| x.is_a?(String) } : []
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def opt_obj(v)
|
|
454
|
+
v.is_a?(Hash) ? v : nil
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def to_tokens(v)
|
|
458
|
+
d = v.is_a?(Hash) ? v : nil
|
|
459
|
+
V2Tokens.new(input: num(d && d["input"]), output: num(d && d["output"]))
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def to_output_artifact(v)
|
|
463
|
+
d = v.is_a?(Hash) ? v : {}
|
|
464
|
+
V2OutputArtifact.new(
|
|
465
|
+
url: opt_str(d["url"]),
|
|
466
|
+
object_key: str(d["object_key"]),
|
|
467
|
+
size_bytes: num(d["size_bytes"]),
|
|
468
|
+
content_type: str(d["content_type"], "application/octet-stream"),
|
|
469
|
+
expires_in: num(d["expires_in"], 900)
|
|
470
|
+
)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def to_perceive_result(d)
|
|
474
|
+
raw_outputs = opt_obj(d["outputs"]) || {}
|
|
475
|
+
outputs = {}
|
|
476
|
+
raw_outputs.each { |name, artifact| outputs[name] = to_output_artifact(artifact) }
|
|
477
|
+
PerceiveResult.new(
|
|
478
|
+
operation_id: str(d["operation_id"]),
|
|
479
|
+
status: d["status"],
|
|
480
|
+
url: str(d["url"]),
|
|
481
|
+
url_final: opt_str(d["url_final"]),
|
|
482
|
+
content_hash: opt_str(d["content_hash"]),
|
|
483
|
+
render_quality: opt_num(d["render_quality"]),
|
|
484
|
+
cache_hit: d["cache_hit"] == true,
|
|
485
|
+
outputs: outputs,
|
|
486
|
+
structured: opt_obj(d["structured"]),
|
|
487
|
+
extraction_tier: opt_str(d["extraction_tier"]),
|
|
488
|
+
tokens: to_tokens(d["tokens"]),
|
|
489
|
+
cost_cents: num(d["cost_cents"]),
|
|
490
|
+
duration_ms: opt_num(d["duration_ms"]),
|
|
491
|
+
error: opt_str(d["error"]),
|
|
492
|
+
warnings: str_arr(d["warnings"])
|
|
493
|
+
)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def to_perceive_batch_result(d)
|
|
497
|
+
items = d["items"].is_a?(Array) ? d["items"] : []
|
|
498
|
+
PerceiveBatchResult.new(
|
|
499
|
+
job_id: str(d["job_id"]),
|
|
500
|
+
status: d["status"],
|
|
501
|
+
output_mode: opt_str(d["output_mode"]) || "manifest",
|
|
502
|
+
total: num(d["total"]),
|
|
503
|
+
completed: num(d["completed"]),
|
|
504
|
+
failed: num(d["failed"]),
|
|
505
|
+
pending: num(d["pending"]),
|
|
506
|
+
zip: d["zip"].nil? ? nil : to_output_artifact(d["zip"]),
|
|
507
|
+
items: items.map { |i| to_perceive_result(i) },
|
|
508
|
+
warnings: str_arr(d["warnings"])
|
|
509
|
+
)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def to_discover_result(d)
|
|
513
|
+
DiscoverResult.new(
|
|
514
|
+
url: str(d["url"]),
|
|
515
|
+
mode: d["mode"],
|
|
516
|
+
total: num(d["total"]),
|
|
517
|
+
urls: str_arr(d["urls"]),
|
|
518
|
+
pages_crawled: num(d["pages_crawled"]),
|
|
519
|
+
truncated: d["truncated"] == true,
|
|
520
|
+
robots_respected: d["robots_respected"] == true,
|
|
521
|
+
sources: opt_obj(d["sources"]) || {},
|
|
522
|
+
warnings: str_arr(d["warnings"])
|
|
523
|
+
)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def to_lookup_item(d)
|
|
527
|
+
perceive = opt_obj(d["perceive"])
|
|
528
|
+
LookupItem.new(
|
|
529
|
+
title: opt_str(d["title"]),
|
|
530
|
+
url: opt_str(d["url"]),
|
|
531
|
+
snippet: opt_str(d["snippet"]),
|
|
532
|
+
position: opt_num(d["position"]),
|
|
533
|
+
source: opt_str(d["source"]),
|
|
534
|
+
date: opt_str(d["date"]),
|
|
535
|
+
image_url: opt_str(d["image_url"]),
|
|
536
|
+
thumbnail_url: opt_str(d["thumbnail_url"]),
|
|
537
|
+
extra: opt_obj(d["extra"]) || {},
|
|
538
|
+
perceive: perceive ? to_perceive_result(perceive) : nil
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def to_lookup_result(d)
|
|
543
|
+
results = d["results"].is_a?(Array) ? d["results"] : []
|
|
544
|
+
LookupResult.new(
|
|
545
|
+
lookup_id: opt_num(d["lookup_id"]),
|
|
546
|
+
query: str(d["query"]),
|
|
547
|
+
category: d["category"],
|
|
548
|
+
country: opt_str(d["country"]),
|
|
549
|
+
locale: opt_str(d["locale"]),
|
|
550
|
+
time_filter: opt_str(d["time_filter"]),
|
|
551
|
+
total: num(d["total"]),
|
|
552
|
+
results: results.map { |r| to_lookup_item(r) },
|
|
553
|
+
perceive_top: num(d["perceive_top"]),
|
|
554
|
+
perceive_operation_ids: str_arr(d["perceive_operation_ids"]),
|
|
555
|
+
answer_box: opt_obj(d["answer_box"]),
|
|
556
|
+
knowledge_graph: opt_obj(d["knowledge_graph"]),
|
|
557
|
+
credits: opt_num(d["credits"]),
|
|
558
|
+
cost_cents: num(d["cost_cents"]),
|
|
559
|
+
warnings: str_arr(d["warnings"])
|
|
560
|
+
)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def to_distill_item(d)
|
|
564
|
+
DistillItem.new(
|
|
565
|
+
url: str(d["url"]),
|
|
566
|
+
url_final: opt_str(d["url_final"]),
|
|
567
|
+
status: opt_str(d["status"]) || "completed",
|
|
568
|
+
data: opt_obj(d["data"]),
|
|
569
|
+
extraction_tier: opt_str(d["extraction_tier"]) || "none",
|
|
570
|
+
fields_from_css: num(d["fields_from_css"]),
|
|
571
|
+
fields_from_llm: num(d["fields_from_llm"]),
|
|
572
|
+
render_quality: opt_num(d["render_quality"]),
|
|
573
|
+
tokens: to_tokens(d["tokens"]),
|
|
574
|
+
cost_cents: num(d["cost_cents"]),
|
|
575
|
+
error: opt_str(d["error"]),
|
|
576
|
+
warnings: str_arr(d["warnings"])
|
|
577
|
+
)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def to_distill_result(d)
|
|
581
|
+
results = d["results"].is_a?(Array) ? d["results"] : []
|
|
582
|
+
DistillResult.new(
|
|
583
|
+
operation_id: str(d["operation_id"]),
|
|
584
|
+
total: num(d["total"]),
|
|
585
|
+
completed: num(d["completed"]),
|
|
586
|
+
failed: num(d["failed"]),
|
|
587
|
+
results: results.map { |r| to_distill_item(r) },
|
|
588
|
+
total_cost_cents: num(d["total_cost_cents"]),
|
|
589
|
+
warnings: str_arr(d["warnings"])
|
|
590
|
+
)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def to_ingest_job(d)
|
|
594
|
+
IngestJob.new(
|
|
595
|
+
job_id: str(d["job_id"]),
|
|
596
|
+
status: d["status"],
|
|
597
|
+
mode: d["mode"],
|
|
598
|
+
pages_discovered: num(d["pages_discovered"]),
|
|
599
|
+
pages_processed: num(d["pages_processed"]),
|
|
600
|
+
pages_failed: num(d["pages_failed"]),
|
|
601
|
+
total_chunks: num(d["total_chunks"]),
|
|
602
|
+
output_url: opt_str(d["output_url"]),
|
|
603
|
+
error_message: opt_str(d["error_message"]),
|
|
604
|
+
webhook_url: opt_str(d["webhook_url"]),
|
|
605
|
+
webhook_delivered: d["webhook_delivered"] == true,
|
|
606
|
+
created_at: opt_str(d["created_at"]),
|
|
607
|
+
completed_at: opt_str(d["completed_at"]),
|
|
608
|
+
warnings: str_arr(d["warnings"])
|
|
609
|
+
)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def to_ingest_job_summary(d)
|
|
613
|
+
IngestJobSummary.new(
|
|
614
|
+
job_id: str(d["job_id"]),
|
|
615
|
+
status: d["status"],
|
|
616
|
+
mode: d["mode"],
|
|
617
|
+
pages_discovered: num(d["pages_discovered"]),
|
|
618
|
+
pages_processed: num(d["pages_processed"]),
|
|
619
|
+
pages_failed: num(d["pages_failed"]),
|
|
620
|
+
total_chunks: num(d["total_chunks"]),
|
|
621
|
+
output_url: opt_str(d["output_url"]),
|
|
622
|
+
error_message: opt_str(d["error_message"]),
|
|
623
|
+
webhook_configured: d["webhook_configured"] == true,
|
|
624
|
+
webhook_delivered: d["webhook_delivered"] == true,
|
|
625
|
+
created_at: opt_str(d["created_at"]),
|
|
626
|
+
completed_at: opt_str(d["completed_at"])
|
|
627
|
+
)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def to_webhook_secret(d)
|
|
631
|
+
WebhookSecret.new(
|
|
632
|
+
secret: str(d["secret"]),
|
|
633
|
+
signature_header: str(d["signature_header"]),
|
|
634
|
+
timestamp_header: str(d["timestamp_header"]),
|
|
635
|
+
signature_scheme: str(d["signature_scheme"]),
|
|
636
|
+
replay_tolerance_seconds: num(d["replay_tolerance_seconds"]),
|
|
637
|
+
rotated: d["rotated"] == true
|
|
638
|
+
)
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def to_watcher(d)
|
|
642
|
+
Watcher.new(
|
|
643
|
+
watcher_id: str(d["watcher_id"]),
|
|
644
|
+
url: str(d["url"]),
|
|
645
|
+
status: d["status"],
|
|
646
|
+
frequency_minutes: num(d["frequency_minutes"]),
|
|
647
|
+
diff_mode: d["diff_mode"],
|
|
648
|
+
track_fields: opt_obj(d["track_fields"]),
|
|
649
|
+
webhook_url: opt_str(d["webhook_url"]),
|
|
650
|
+
notify_email: d["notify_email"] != false,
|
|
651
|
+
consecutive_errors: num(d["consecutive_errors"]),
|
|
652
|
+
checks_count: num(d["checks_count"]),
|
|
653
|
+
last_check_at: opt_str(d["last_check_at"]),
|
|
654
|
+
next_check_at: opt_str(d["next_check_at"]),
|
|
655
|
+
last_change_at: opt_str(d["last_change_at"]),
|
|
656
|
+
created_at: opt_str(d["created_at"]),
|
|
657
|
+
updated_at: opt_str(d["updated_at"])
|
|
658
|
+
)
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def to_watcher_summary(d)
|
|
662
|
+
WatcherSummary.new(
|
|
663
|
+
watcher_id: str(d["watcher_id"]),
|
|
664
|
+
url: str(d["url"]),
|
|
665
|
+
status: d["status"],
|
|
666
|
+
frequency_minutes: num(d["frequency_minutes"]),
|
|
667
|
+
checks_count: num(d["checks_count"]),
|
|
668
|
+
consecutive_errors: num(d["consecutive_errors"]),
|
|
669
|
+
last_check_at: opt_str(d["last_check_at"]),
|
|
670
|
+
next_check_at: opt_str(d["next_check_at"]),
|
|
671
|
+
last_change_at: opt_str(d["last_change_at"]),
|
|
672
|
+
created_at: opt_str(d["created_at"])
|
|
673
|
+
)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def to_watcher_snapshot(d)
|
|
677
|
+
changes = d["changes"].is_a?(Array) ? d["changes"].select { |c| c.is_a?(Hash) } : []
|
|
678
|
+
WatcherSnapshot.new(
|
|
679
|
+
checked_at: str(d["checked_at"]),
|
|
680
|
+
has_changes: d["has_changes"] == true,
|
|
681
|
+
similarity: opt_num(d["similarity"]),
|
|
682
|
+
render_quality: opt_num(d["render_quality"]),
|
|
683
|
+
change_count: num(d["change_count"]),
|
|
684
|
+
changes: changes
|
|
685
|
+
)
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
end
|