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