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.
@@ -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