enconvert 0.0.1 → 0.1.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.
@@ -1,419 +1,422 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "securerandom"
5
- require "fileutils"
6
- require "net/http"
7
- require "uri"
8
-
9
- require_relative "internal"
10
- require_relative "errors"
11
- require_relative "formats"
12
- require_relative "results"
13
- require_relative "v2"
14
-
15
- module Enconvert
16
- # Enconvert file conversion client.
17
- #
18
- # @example
19
- # client = Enconvert::Client.new(api_key: "sk_...")
20
- # result = client.convert_url_to_pdf("https://example.com")
21
- # puts result.presigned_url
22
- class Client
23
- include Internal
24
-
25
- DEFAULT_BASE_URL = "https://api.enconvert.com"
26
- DEFAULT_TIMEOUT = 300
27
- DEFAULT_BATCH_POLL_INTERVAL = 5
28
- DEFAULT_BATCH_TIMEOUT = 1_800
29
-
30
- # V2 API namespace: perceive, discover, lookup, distill, ingest, watch.
31
- # Requires a private API key; endpoints are plan-gated (QuotaError on 402).
32
- attr_reader :v2
33
-
34
- def initialize(api_key:, timeout: DEFAULT_TIMEOUT, base_url: DEFAULT_BASE_URL)
35
- raise Enconvert::Error, "Enconvert: 'api_key' is required" if api_key.nil? || api_key.to_s.strip.empty?
36
-
37
- @api_key = api_key
38
- @base_url = base_url.to_s.sub(%r{/+\z}, "")
39
- @timeout = timeout
40
- @transport = Internal::Transport.new(api_key: @api_key, base_url: @base_url, timeout: @timeout)
41
- @v2 = Enconvert::V2.new(@transport)
42
- end
43
-
44
- # ------------------------------------------------------------------
45
- # URL conversions (single page)
46
- # ------------------------------------------------------------------
47
-
48
- # Convert a URL to PDF.
49
- #
50
- # opts accepts: viewport_width, viewport_height, load_media,
51
- # enable_scroll, output_filename, auth, cookies, headers,
52
- # plus save_to, single_page (default true), pdf_options.
53
- def convert_url_to_pdf(url, save_to: nil, single_page: nil, pdf_options: nil, **opts)
54
- body = build_url_body(url, opts)
55
- body[:single_page] = single_page.nil? ? true : single_page
56
- body[:pdf_options] = serialize_pdf_options(pdf_options) if pdf_options
57
-
58
- data = post_json("/v1/convert/url-to-pdf", body)
59
- result = to_conversion_result(data)
60
- download(result.presigned_url, save_to) if save_to
61
- result
62
- end
63
-
64
- # Convert a URL to a PNG screenshot.
65
- def convert_url_to_screenshot(url, save_to: nil, **opts)
66
- body = build_url_body(url, opts)
67
-
68
- data = post_json("/v1/convert/url-to-screenshot", body)
69
- result = to_conversion_result(data)
70
- download(result.presigned_url, save_to) if save_to
71
- result
72
- end
73
-
74
- # Convert a URL to clean GitHub-Flavored Markdown with YAML frontmatter
75
- # (title, description, url, links, images). Strips nav/footer/ads/scripts
76
- # and extracts the main article content.
77
- def convert_url_to_markdown(url, save_to: nil, **opts)
78
- body = build_url_body(url, opts)
79
-
80
- data = post_json("/v1/convert/url-to-markdown", body)
81
- result = to_conversion_result(data)
82
- download(result.presigned_url, save_to) if save_to
83
- result
84
- end
85
-
86
- # ------------------------------------------------------------------
87
- # Website conversions (async batch, whole-site crawl)
88
- # ------------------------------------------------------------------
89
-
90
- # Convert every discovered page of a website to PDF. Async-only: pages
91
- # are discovered via sitemap or full crawl (plan-dependent), converted
92
- # in the background, and bundled into a single ZIP. Poll with
93
- # get_batch_status or block with wait_for_batch. Requires a private API
94
- # key with crawl access.
95
- def convert_website_to_pdf(url, single_page: nil, pdf_options: nil, **opts)
96
- body = build_website_body(url, opts)
97
- body[:single_page] = single_page unless single_page.nil?
98
- body[:pdf_options] = serialize_pdf_options(pdf_options) if pdf_options
99
-
100
- # No job-polling fallback: website submissions have no per-job row, so
101
- # a 5xx here means the submission itself failed and must surface directly.
102
- data = post_json("/v1/convert/website-to-pdf", body, job_fallback: false)
103
- to_batch_submission(data)
104
- end
105
-
106
- # Screenshot every discovered page of a website (PNG). Async-only,
107
- # bundled into a single ZIP. Poll with get_batch_status or block with
108
- # wait_for_batch. Requires a private API key with crawl access.
109
- def convert_website_to_screenshot(url, **opts)
110
- body = build_website_body(url, opts)
111
-
112
- data = post_json("/v1/convert/website-to-screenshot", body, job_fallback: false)
113
- to_batch_submission(data)
114
- end
115
-
116
- # ------------------------------------------------------------------
117
- # File conversions
118
- # ------------------------------------------------------------------
119
-
120
- # Convert an image between formats (jpeg, png, svg, heic, webp), or
121
- # rasterize a PDF to JPEG. Only pairs implemented by the API are
122
- # accepted; unsupported pairs raise before any request is made.
123
- def convert_image(file, output_format:, save_to: nil, output_filename: nil)
124
- part = to_file_part(file)
125
- input_format = Formats.resolve_input_format(part.filename, Formats::IMAGE_FORMATS)
126
- output_fmt = Formats.normalize_output_format(output_format)
127
- endpoint = "/v1/convert/#{Formats.assert_conversion_implemented(input_format, output_fmt)}"
128
- data = post_file(endpoint, part, output_filename: output_filename)
129
- result = to_conversion_result(data)
130
- download(result.presigned_url, save_to) if save_to
131
- result
132
- end
133
-
134
- # Convert a document (doc, excel, ppt, odt, ods, odp, ots, pages,
135
- # numbers, html, markdown, csv, json, xml, yaml, toml). Output
136
- # defaults to pdf. Only pairs implemented by the API are accepted;
137
- # unsupported pairs raise before any request is made. (EPUB has no
138
- # dedicated pair use convert_to_pdf / convert_to_markdown.)
139
- def convert_document(file, output_format: "pdf", save_to: nil, output_filename: nil, pdf_options: nil)
140
- part = to_file_part(file)
141
- input_format = Formats.resolve_input_format(part.filename, Formats::DOCUMENT_FORMATS)
142
- output_fmt = Formats.normalize_output_format(output_format)
143
- endpoint = "/v1/convert/#{Formats.assert_conversion_implemented(input_format, output_fmt)}"
144
- data = post_file(endpoint, part, output_filename: output_filename, pdf_options: pdf_options)
145
- result = to_conversion_result(data)
146
- download(result.presigned_url, save_to) if save_to
147
- result
148
- end
149
-
150
- # Convert an uploaded file of (almost) any document format to clean
151
- # Markdown — PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT/MD, and
152
- # legacy/ODF office. The format is auto-detected server-side; a
153
- # RAG-ingestion building block. Images are not supported.
154
- def convert_to_markdown(file, save_to: nil, output_filename: nil)
155
- part = to_file_part(file)
156
- data = post_file("/v1/convert/anything-to-markdown", part, output_filename: output_filename)
157
- result = to_conversion_result(data)
158
- download(result.presigned_url, save_to) if save_to
159
- result
160
- end
161
-
162
- # Convert an uploaded file of (almost) any format to PDF —
163
- # office/ODF/Pages/Numbers/RTF/CSV, HTML, Markdown, text, raster
164
- # images, SVG, EPUB, or an existing PDF (passthrough/normalise). The
165
- # format is auto-detected server-side. Only `pdf_options[:grayscale]`
166
- # is honored on this endpoint.
167
- def convert_to_pdf(file, save_to: nil, output_filename: nil, pdf_options: nil)
168
- part = to_file_part(file)
169
- data = post_file("/v1/convert/anything-to-pdf", part, output_filename: output_filename, pdf_options: pdf_options)
170
- result = to_conversion_result(data)
171
- download(result.presigned_url, save_to) if save_to
172
- result
173
- end
174
-
175
- # ------------------------------------------------------------------
176
- # Job + batch status
177
- # ------------------------------------------------------------------
178
-
179
- # Poll the status of an async conversion job.
180
- def get_job_status(job_id)
181
- resp = @transport.request(:get, "/v1/convert/status/#{job_id}")
182
- raise_for_status(resp)
183
- to_job_status(JSON.parse(resp.body))
184
- end
185
-
186
- # Get the status of an async batch (website conversion). Returns
187
- # aggregate counts, per-URL statuses, and download URLs. Private API
188
- # keys only.
189
- def get_batch_status(batch_id)
190
- resp = @transport.request(:get, "/v1/convert/batch/#{batch_id}")
191
- raise_for_status(resp)
192
- to_batch_status(JSON.parse(resp.body))
193
- end
194
-
195
- # Poll a batch until it leaves "processing", then return its final
196
- # status. With save_to, downloads the batch ZIP once available.
197
- # Raises APIError 504 on timeout.
198
- def wait_for_batch(batch_id, interval: DEFAULT_BATCH_POLL_INTERVAL, timeout: DEFAULT_BATCH_TIMEOUT, save_to: nil)
199
- deadline = Time.now + timeout
200
-
201
- loop do
202
- status = get_batch_status(batch_id)
203
- if status.status != "processing"
204
- if save_to
205
- if status.zip_download_url.nil?
206
- raise APIError.new(
207
- 500, "Batch #{batch_id} finished with status '#{status.status}' but no ZIP is available to save"
208
- )
209
- end
210
- download(status.zip_download_url, save_to)
211
- end
212
- return status
213
- end
214
- raise APIError.new(504, "Batch #{batch_id} did not complete within #{timeout}s") if Time.now >= deadline
215
-
216
- sleep(interval)
217
- end
218
- end
219
-
220
- private
221
-
222
- # ------------------------------------------------------------------
223
- # Internal helpers (mirror of Node's postJson / postFile / pollJob /
224
- # download / toFilePart / raiseForStatus)
225
- # ------------------------------------------------------------------
226
-
227
- # Request body shared by all single-URL conversions.
228
- def build_url_body(url, opts)
229
- body = {
230
- url: url,
231
- direct_download: false,
232
- viewport_width: opts.key?(:viewport_width) ? opts[:viewport_width] : 1920,
233
- viewport_height: opts.key?(:viewport_height) ? opts[:viewport_height] : 1080,
234
- load_media: opts.key?(:load_media) ? opts[:load_media] : true,
235
- enable_scroll: opts.key?(:enable_scroll) ? opts[:enable_scroll] : true
236
- }
237
- body[:output_filename] = opts[:output_filename] if opts[:output_filename]
238
- append_browser_access(body, opts)
239
- body
240
- end
241
-
242
- # Request body for website (whole-site) conversions. Render options are
243
- # only sent when set — the gateway applies the same defaults per page.
244
- def build_website_body(url, opts)
245
- body = { url: url }
246
- body[:crawl_mode] = opts[:crawl_mode] if opts[:crawl_mode]
247
- body[:include_patterns] = opts[:include_patterns] if opts[:include_patterns]
248
- body[:exclude_patterns] = opts[:exclude_patterns] if opts[:exclude_patterns]
249
- body[:notification_email] = opts[:notification_email] if opts[:notification_email]
250
- body[:callback_url] = opts[:callback_url] if opts[:callback_url]
251
- body[:output_filename] = opts[:output_filename] if opts[:output_filename]
252
- body[:viewport_width] = opts[:viewport_width] if opts.key?(:viewport_width)
253
- body[:viewport_height] = opts[:viewport_height] if opts.key?(:viewport_height)
254
- body[:load_media] = opts[:load_media] if opts.key?(:load_media)
255
- body[:enable_scroll] = opts[:enable_scroll] if opts.key?(:enable_scroll)
256
- append_browser_access(body, opts)
257
- body
258
- end
259
-
260
- # Attach the plan-gated auth/cookies/headers fields when provided.
261
- def append_browser_access(body, opts)
262
- body[:auth] = opts[:auth] if opts[:auth]
263
- body[:cookies] = opts[:cookies] if opts[:cookies]
264
- body[:headers] = opts[:headers] if opts[:headers]
265
- end
266
-
267
- def post_json(endpoint, body, job_fallback: true)
268
- job_id = job_fallback ? new_job_id : nil
269
- body[:job_id] = job_id if job_id
270
- begin
271
- resp = @transport.request(
272
- :post, endpoint,
273
- headers: { "Content-Type" => "application/json" },
274
- body: JSON.generate(body)
275
- )
276
- raise_for_status(resp)
277
- data = JSON.parse(resp.body)
278
- # Some success responses omit job_id (URL sync path); backfill the
279
- # client-generated id so callers can still poll get_job_status with it.
280
- job_id ? { "job_id" => job_id }.merge(data) : data
281
- rescue APIError => e
282
- raise unless job_id && e.status_code >= 500
283
-
284
- poll_job(job_id)
285
- end
286
- end
287
-
288
- def post_file(endpoint, part, output_filename: nil, pdf_options: nil)
289
- job_id = new_job_id
290
- fields = [
291
- { name: "file", value: part.bytes, filename: part.filename, content_type: part.content_type },
292
- { name: "direct_download", value: "false" },
293
- { name: "job_id", value: job_id }
294
- ]
295
- fields << { name: "output_filename", value: output_filename } if output_filename
296
- fields << { name: "pdf_options", value: JSON.generate(serialize_pdf_options(pdf_options)) } if pdf_options
297
-
298
- body, boundary = build_multipart(fields)
299
- begin
300
- resp = @transport.request(
301
- :post, endpoint,
302
- headers: { "Content-Type" => "multipart/form-data; boundary=#{boundary}" },
303
- body: body
304
- )
305
- raise_for_status(resp)
306
- data = JSON.parse(resp.body)
307
- { "job_id" => job_id }.merge(data)
308
- rescue APIError => e
309
- raise unless e.status_code >= 500
310
-
311
- poll_job(job_id)
312
- end
313
- end
314
-
315
- # Poll job status until success/failure. Used as fallback when the
316
- # HTTP request fails.
317
- def poll_job(job_id, max_wait: 300, interval: 3)
318
- deadline = Time.now + max_wait
319
- while Time.now < deadline
320
- sleep(interval)
321
- resp = @transport.request(:get, "/v1/convert/status/#{job_id}")
322
- next if resp.code.to_i == 404
323
-
324
- raise_for_status(resp)
325
- data = JSON.parse(resp.body)
326
- return data if data["status"] == "success"
327
- raise APIError.new(500, data["error"] || "Conversion failed") if data["status"] == "failed"
328
- end
329
- raise APIError.new(504, "Conversion timed out")
330
- end
331
-
332
- # Save a presigned URL to a local file. Streams the response to disk.
333
- # Deliberately bypasses @transport: this is a signed S3 URL and must
334
- # NOT receive the X-API-Key header.
335
- def download(url, dest)
336
- uri = URI(url)
337
- FileUtils.mkdir_p(File.dirname(dest))
338
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
339
- req = Net::HTTP::Get.new(uri)
340
- http.request(req) do |response|
341
- unless response.is_a?(Net::HTTPSuccess)
342
- raise APIError.new(response.code.to_i, "Failed to download: #{response.message}")
343
- end
344
-
345
- File.open(dest, "wb") do |file|
346
- response.read_body { |chunk| file.write(chunk) }
347
- end
348
- end
349
- end
350
- end
351
-
352
- # ------------------------------------------------------------------
353
- # Response mappers
354
- # ------------------------------------------------------------------
355
-
356
- def to_conversion_result(data)
357
- object_key = data["object_key"].is_a?(String) ? data["object_key"] : ""
358
- # Job-status fallback responses omit `filename`; recover it from the
359
- # object key so callers never see a nil/blank filename silently.
360
- filename = data["filename"].is_a?(String) ? data["filename"] : basename_fallback(object_key)
361
- ConversionResult.new(
362
- presigned_url: data["presigned_url"].to_s,
363
- object_key: object_key,
364
- filename: filename,
365
- file_size: data["file_size"].is_a?(Numeric) ? data["file_size"] : nil,
366
- conversion_time_seconds: data["conversion_time_seconds"].is_a?(Numeric) ? data["conversion_time_seconds"] : nil,
367
- job_id: data["job_id"].is_a?(String) ? data["job_id"] : nil
368
- )
369
- end
370
-
371
- def basename_fallback(object_key)
372
- parts = object_key.to_s.split("/")
373
- parts.empty? ? "" : parts.last
374
- end
375
-
376
- def to_job_status(data)
377
- JobStatus.new(
378
- status: data["status"],
379
- presigned_url: data["presigned_url"].is_a?(String) ? data["presigned_url"] : nil,
380
- object_key: data["object_key"].is_a?(String) ? data["object_key"] : nil,
381
- error: data["error"].is_a?(String) ? data["error"] : nil
382
- )
383
- end
384
-
385
- def to_batch_submission(data)
386
- BatchSubmission.new(
387
- batch_id: data["batch_id"].to_s,
388
- status: data["status"].is_a?(String) ? data["status"] : "processing",
389
- url_count: data["url_count"].is_a?(Numeric) ? data["url_count"] : 0,
390
- total_discovered: data["total_discovered"].is_a?(Numeric) ? data["total_discovered"] : nil,
391
- discovery_method: data["discovery_method"].is_a?(String) ? data["discovery_method"] : nil,
392
- output_format: data["output_format"].is_a?(String) ? data["output_format"] : nil
393
- )
394
- end
395
-
396
- def to_batch_status(d)
397
- raw_items = d["items"].is_a?(Array) ? d["items"] : []
398
- BatchStatus.new(
399
- batch_id: d["batch_id"].to_s,
400
- status: d["status"],
401
- total: d["total"].is_a?(Numeric) ? d["total"] : 0,
402
- completed: d["completed"].is_a?(Numeric) ? d["completed"] : 0,
403
- failed: d["failed"].is_a?(Numeric) ? d["failed"] : 0,
404
- in_progress: d["in_progress"].is_a?(Numeric) ? d["in_progress"] : 0,
405
- output_mode: d["output_mode"],
406
- zip_download_url: d["zip_download_url"].is_a?(String) ? d["zip_download_url"] : nil,
407
- items: raw_items.map do |item|
408
- BatchItem.new(
409
- source_url: item["source_url"].is_a?(String) ? item["source_url"] : "",
410
- status: item["status"].is_a?(String) ? item["status"] : "",
411
- download_url: item["download_url"].is_a?(String) ? item["download_url"] : nil,
412
- output_file_size: item["output_file_size"].is_a?(Numeric) ? item["output_file_size"] : nil,
413
- duration: item["duration"].is_a?(String) ? item["duration"] : nil
414
- )
415
- end
416
- )
417
- end
418
- end
419
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "fileutils"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ require_relative "internal"
10
+ require_relative "errors"
11
+ require_relative "formats"
12
+ require_relative "results"
13
+ require_relative "v2"
14
+
15
+ module Enconvert
16
+ # Enconvert file conversion client.
17
+ #
18
+ # @example
19
+ # client = Enconvert::Client.new(api_key: "sk_...")
20
+ # result = client.convert_url_to_pdf("https://example.com")
21
+ # puts result.presigned_url
22
+ class Client
23
+ include Internal
24
+
25
+ DEFAULT_BASE_URL = "https://api.enconvert.com"
26
+ DEFAULT_TIMEOUT = 300
27
+ DEFAULT_BATCH_POLL_INTERVAL = 5
28
+ DEFAULT_BATCH_TIMEOUT = 1_800
29
+
30
+ # V2 API namespace: perceive, discover, lookup, distill, ingest, watch.
31
+ # Requires a private API key; endpoints are plan-gated (QuotaError on 402).
32
+ attr_reader :v2
33
+
34
+ # `user_agent` overrides the default "enconvert-sdk/<version> (ruby)"
35
+ # User-Agent sent on every gateway request.
36
+ def initialize(api_key:, timeout: DEFAULT_TIMEOUT, base_url: DEFAULT_BASE_URL, user_agent: nil)
37
+ raise Enconvert::Error, "Enconvert: 'api_key' is required" if api_key.nil? || api_key.to_s.strip.empty?
38
+
39
+ @api_key = api_key
40
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
41
+ @timeout = timeout
42
+ @transport = Internal::Transport.new(api_key: @api_key, base_url: @base_url, timeout: @timeout,
43
+ user_agent: user_agent)
44
+ @v2 = Enconvert::V2.new(@transport)
45
+ end
46
+
47
+ # ------------------------------------------------------------------
48
+ # URL conversions (single page)
49
+ # ------------------------------------------------------------------
50
+
51
+ # Convert a URL to PDF.
52
+ #
53
+ # opts accepts: viewport_width, viewport_height, load_media,
54
+ # enable_scroll, output_filename, auth, cookies, headers,
55
+ # plus save_to, single_page (default true), pdf_options.
56
+ def convert_url_to_pdf(url, save_to: nil, single_page: nil, pdf_options: nil, **opts)
57
+ body = build_url_body(url, opts)
58
+ body[:single_page] = single_page.nil? ? true : single_page
59
+ body[:pdf_options] = serialize_pdf_options(pdf_options) if pdf_options
60
+
61
+ data = post_json("/v1/convert/url-to-pdf", body)
62
+ result = to_conversion_result(data)
63
+ download(result.presigned_url, save_to) if save_to
64
+ result
65
+ end
66
+
67
+ # Convert a URL to a PNG screenshot.
68
+ def convert_url_to_screenshot(url, save_to: nil, **opts)
69
+ body = build_url_body(url, opts)
70
+
71
+ data = post_json("/v1/convert/url-to-screenshot", body)
72
+ result = to_conversion_result(data)
73
+ download(result.presigned_url, save_to) if save_to
74
+ result
75
+ end
76
+
77
+ # Convert a URL to clean GitHub-Flavored Markdown with YAML frontmatter
78
+ # (title, description, url, links, images). Strips nav/footer/ads/scripts
79
+ # and extracts the main article content.
80
+ def convert_url_to_markdown(url, save_to: nil, **opts)
81
+ body = build_url_body(url, opts)
82
+
83
+ data = post_json("/v1/convert/url-to-markdown", body)
84
+ result = to_conversion_result(data)
85
+ download(result.presigned_url, save_to) if save_to
86
+ result
87
+ end
88
+
89
+ # ------------------------------------------------------------------
90
+ # Website conversions (async batch, whole-site crawl)
91
+ # ------------------------------------------------------------------
92
+
93
+ # Convert every discovered page of a website to PDF. Async-only: pages
94
+ # are discovered via sitemap or full crawl (plan-dependent), converted
95
+ # in the background, and bundled into a single ZIP. Poll with
96
+ # get_batch_status or block with wait_for_batch. Requires a private API
97
+ # key with crawl access.
98
+ def convert_website_to_pdf(url, single_page: nil, pdf_options: nil, **opts)
99
+ body = build_website_body(url, opts)
100
+ body[:single_page] = single_page unless single_page.nil?
101
+ body[:pdf_options] = serialize_pdf_options(pdf_options) if pdf_options
102
+
103
+ # No job-polling fallback: website submissions have no per-job row, so
104
+ # a 5xx here means the submission itself failed and must surface directly.
105
+ data = post_json("/v1/convert/website-to-pdf", body, job_fallback: false)
106
+ to_batch_submission(data)
107
+ end
108
+
109
+ # Screenshot every discovered page of a website (PNG). Async-only,
110
+ # bundled into a single ZIP. Poll with get_batch_status or block with
111
+ # wait_for_batch. Requires a private API key with crawl access.
112
+ def convert_website_to_screenshot(url, **opts)
113
+ body = build_website_body(url, opts)
114
+
115
+ data = post_json("/v1/convert/website-to-screenshot", body, job_fallback: false)
116
+ to_batch_submission(data)
117
+ end
118
+
119
+ # ------------------------------------------------------------------
120
+ # File conversions
121
+ # ------------------------------------------------------------------
122
+
123
+ # Convert an image between formats (jpeg, png, svg, heic, webp), or
124
+ # rasterize a PDF to JPEG. Only pairs implemented by the API are
125
+ # accepted; unsupported pairs raise before any request is made.
126
+ def convert_image(file, output_format:, save_to: nil, output_filename: nil)
127
+ part = to_file_part(file)
128
+ input_format = Formats.resolve_input_format(part.filename, Formats::IMAGE_FORMATS)
129
+ output_fmt = Formats.normalize_output_format(output_format)
130
+ endpoint = "/v1/convert/#{Formats.assert_conversion_implemented(input_format, output_fmt)}"
131
+ data = post_file(endpoint, part, output_filename: output_filename)
132
+ result = to_conversion_result(data)
133
+ download(result.presigned_url, save_to) if save_to
134
+ result
135
+ end
136
+
137
+ # Convert a document (doc, excel, ppt, odt, ods, odp, ots, pages,
138
+ # numbers, html, markdown, csv, json, xml, yaml, toml). Output
139
+ # defaults to pdf. Only pairs implemented by the API are accepted;
140
+ # unsupported pairs raise before any request is made. (EPUB has no
141
+ # dedicated pair — use convert_to_pdf / convert_to_markdown.)
142
+ def convert_document(file, output_format: "pdf", save_to: nil, output_filename: nil, pdf_options: nil)
143
+ part = to_file_part(file)
144
+ input_format = Formats.resolve_input_format(part.filename, Formats::DOCUMENT_FORMATS)
145
+ output_fmt = Formats.normalize_output_format(output_format)
146
+ endpoint = "/v1/convert/#{Formats.assert_conversion_implemented(input_format, output_fmt)}"
147
+ data = post_file(endpoint, part, output_filename: output_filename, pdf_options: pdf_options)
148
+ result = to_conversion_result(data)
149
+ download(result.presigned_url, save_to) if save_to
150
+ result
151
+ end
152
+
153
+ # Convert an uploaded file of (almost) any document format to clean
154
+ # Markdown — PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT/MD, and
155
+ # legacy/ODF office. The format is auto-detected server-side; a
156
+ # RAG-ingestion building block. Images are not supported.
157
+ def convert_to_markdown(file, save_to: nil, output_filename: nil)
158
+ part = to_file_part(file)
159
+ data = post_file("/v1/convert/anything-to-markdown", part, output_filename: output_filename)
160
+ result = to_conversion_result(data)
161
+ download(result.presigned_url, save_to) if save_to
162
+ result
163
+ end
164
+
165
+ # Convert an uploaded file of (almost) any format to PDF —
166
+ # office/ODF/Pages/Numbers/RTF/CSV, HTML, Markdown, text, raster
167
+ # images, SVG, EPUB, or an existing PDF (passthrough/normalise). The
168
+ # format is auto-detected server-side. Only `pdf_options[:grayscale]`
169
+ # is honored on this endpoint.
170
+ def convert_to_pdf(file, save_to: nil, output_filename: nil, pdf_options: nil)
171
+ part = to_file_part(file)
172
+ data = post_file("/v1/convert/anything-to-pdf", part, output_filename: output_filename, pdf_options: pdf_options)
173
+ result = to_conversion_result(data)
174
+ download(result.presigned_url, save_to) if save_to
175
+ result
176
+ end
177
+
178
+ # ------------------------------------------------------------------
179
+ # Job + batch status
180
+ # ------------------------------------------------------------------
181
+
182
+ # Poll the status of an async conversion job.
183
+ def get_job_status(job_id)
184
+ resp = @transport.request(:get, "/v1/convert/status/#{job_id}")
185
+ raise_for_status(resp)
186
+ to_job_status(JSON.parse(resp.body))
187
+ end
188
+
189
+ # Get the status of an async batch (website conversion). Returns
190
+ # aggregate counts, per-URL statuses, and download URLs. Private API
191
+ # keys only.
192
+ def get_batch_status(batch_id)
193
+ resp = @transport.request(:get, "/v1/convert/batch/#{batch_id}")
194
+ raise_for_status(resp)
195
+ to_batch_status(JSON.parse(resp.body))
196
+ end
197
+
198
+ # Poll a batch until it leaves "processing", then return its final
199
+ # status. With save_to, downloads the batch ZIP once available.
200
+ # Raises APIError 504 on timeout.
201
+ def wait_for_batch(batch_id, interval: DEFAULT_BATCH_POLL_INTERVAL, timeout: DEFAULT_BATCH_TIMEOUT, save_to: nil)
202
+ deadline = Time.now + timeout
203
+
204
+ loop do
205
+ status = get_batch_status(batch_id)
206
+ if status.status != "processing"
207
+ if save_to
208
+ if status.zip_download_url.nil?
209
+ raise APIError.new(
210
+ 500, "Batch #{batch_id} finished with status '#{status.status}' but no ZIP is available to save"
211
+ )
212
+ end
213
+ download(status.zip_download_url, save_to)
214
+ end
215
+ return status
216
+ end
217
+ raise APIError.new(504, "Batch #{batch_id} did not complete within #{timeout}s") if Time.now >= deadline
218
+
219
+ sleep(interval)
220
+ end
221
+ end
222
+
223
+ private
224
+
225
+ # ------------------------------------------------------------------
226
+ # Internal helpers (mirror of Node's postJson / postFile / pollJob /
227
+ # download / toFilePart / raiseForStatus)
228
+ # ------------------------------------------------------------------
229
+
230
+ # Request body shared by all single-URL conversions.
231
+ def build_url_body(url, opts)
232
+ body = {
233
+ url: url,
234
+ direct_download: false,
235
+ viewport_width: opts.key?(:viewport_width) ? opts[:viewport_width] : 1920,
236
+ viewport_height: opts.key?(:viewport_height) ? opts[:viewport_height] : 1080,
237
+ load_media: opts.key?(:load_media) ? opts[:load_media] : true,
238
+ enable_scroll: opts.key?(:enable_scroll) ? opts[:enable_scroll] : true
239
+ }
240
+ body[:output_filename] = opts[:output_filename] if opts[:output_filename]
241
+ append_browser_access(body, opts)
242
+ body
243
+ end
244
+
245
+ # Request body for website (whole-site) conversions. Render options are
246
+ # only sent when set — the gateway applies the same defaults per page.
247
+ def build_website_body(url, opts)
248
+ body = { url: url }
249
+ body[:crawl_mode] = opts[:crawl_mode] if opts[:crawl_mode]
250
+ body[:include_patterns] = opts[:include_patterns] if opts[:include_patterns]
251
+ body[:exclude_patterns] = opts[:exclude_patterns] if opts[:exclude_patterns]
252
+ body[:notification_email] = opts[:notification_email] if opts[:notification_email]
253
+ body[:callback_url] = opts[:callback_url] if opts[:callback_url]
254
+ body[:output_filename] = opts[:output_filename] if opts[:output_filename]
255
+ body[:viewport_width] = opts[:viewport_width] if opts.key?(:viewport_width)
256
+ body[:viewport_height] = opts[:viewport_height] if opts.key?(:viewport_height)
257
+ body[:load_media] = opts[:load_media] if opts.key?(:load_media)
258
+ body[:enable_scroll] = opts[:enable_scroll] if opts.key?(:enable_scroll)
259
+ append_browser_access(body, opts)
260
+ body
261
+ end
262
+
263
+ # Attach the plan-gated auth/cookies/headers fields when provided.
264
+ def append_browser_access(body, opts)
265
+ body[:auth] = opts[:auth] if opts[:auth]
266
+ body[:cookies] = opts[:cookies] if opts[:cookies]
267
+ body[:headers] = opts[:headers] if opts[:headers]
268
+ end
269
+
270
+ def post_json(endpoint, body, job_fallback: true)
271
+ job_id = job_fallback ? new_job_id : nil
272
+ body[:job_id] = job_id if job_id
273
+ begin
274
+ resp = @transport.request(
275
+ :post, endpoint,
276
+ headers: { "Content-Type" => "application/json" },
277
+ body: JSON.generate(body)
278
+ )
279
+ raise_for_status(resp)
280
+ data = JSON.parse(resp.body)
281
+ # Some success responses omit job_id (URL sync path); backfill the
282
+ # client-generated id so callers can still poll get_job_status with it.
283
+ job_id ? { "job_id" => job_id }.merge(data) : data
284
+ rescue APIError => e
285
+ raise unless job_id && e.status_code >= 500
286
+
287
+ poll_job(job_id)
288
+ end
289
+ end
290
+
291
+ def post_file(endpoint, part, output_filename: nil, pdf_options: nil)
292
+ job_id = new_job_id
293
+ fields = [
294
+ { name: "file", value: part.bytes, filename: part.filename, content_type: part.content_type },
295
+ { name: "direct_download", value: "false" },
296
+ { name: "job_id", value: job_id }
297
+ ]
298
+ fields << { name: "output_filename", value: output_filename } if output_filename
299
+ fields << { name: "pdf_options", value: JSON.generate(serialize_pdf_options(pdf_options)) } if pdf_options
300
+
301
+ body, boundary = build_multipart(fields)
302
+ begin
303
+ resp = @transport.request(
304
+ :post, endpoint,
305
+ headers: { "Content-Type" => "multipart/form-data; boundary=#{boundary}" },
306
+ body: body
307
+ )
308
+ raise_for_status(resp)
309
+ data = JSON.parse(resp.body)
310
+ { "job_id" => job_id }.merge(data)
311
+ rescue APIError => e
312
+ raise unless e.status_code >= 500
313
+
314
+ poll_job(job_id)
315
+ end
316
+ end
317
+
318
+ # Poll job status until success/failure. Used as fallback when the
319
+ # HTTP request fails.
320
+ def poll_job(job_id, max_wait: 300, interval: 3)
321
+ deadline = Time.now + max_wait
322
+ while Time.now < deadline
323
+ sleep(interval)
324
+ resp = @transport.request(:get, "/v1/convert/status/#{job_id}")
325
+ next if resp.code.to_i == 404
326
+
327
+ raise_for_status(resp)
328
+ data = JSON.parse(resp.body)
329
+ return data if data["status"] == "success"
330
+ raise APIError.new(500, data["error"] || "Conversion failed") if data["status"] == "failed"
331
+ end
332
+ raise APIError.new(504, "Conversion timed out")
333
+ end
334
+
335
+ # Save a presigned URL to a local file. Streams the response to disk.
336
+ # Deliberately bypasses @transport: this is a signed S3 URL and must
337
+ # NOT receive the X-API-Key header.
338
+ def download(url, dest)
339
+ uri = URI(url)
340
+ FileUtils.mkdir_p(File.dirname(dest))
341
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
342
+ req = Net::HTTP::Get.new(uri)
343
+ http.request(req) do |response|
344
+ unless response.is_a?(Net::HTTPSuccess)
345
+ raise APIError.new(response.code.to_i, "Failed to download: #{response.message}")
346
+ end
347
+
348
+ File.open(dest, "wb") do |file|
349
+ response.read_body { |chunk| file.write(chunk) }
350
+ end
351
+ end
352
+ end
353
+ end
354
+
355
+ # ------------------------------------------------------------------
356
+ # Response mappers
357
+ # ------------------------------------------------------------------
358
+
359
+ def to_conversion_result(data)
360
+ object_key = data["object_key"].is_a?(String) ? data["object_key"] : ""
361
+ # Job-status fallback responses omit `filename`; recover it from the
362
+ # object key so callers never see a nil/blank filename silently.
363
+ filename = data["filename"].is_a?(String) ? data["filename"] : basename_fallback(object_key)
364
+ ConversionResult.new(
365
+ presigned_url: data["presigned_url"].to_s,
366
+ object_key: object_key,
367
+ filename: filename,
368
+ file_size: data["file_size"].is_a?(Numeric) ? data["file_size"] : nil,
369
+ conversion_time_seconds: data["conversion_time_seconds"].is_a?(Numeric) ? data["conversion_time_seconds"] : nil,
370
+ job_id: data["job_id"].is_a?(String) ? data["job_id"] : nil
371
+ )
372
+ end
373
+
374
+ def basename_fallback(object_key)
375
+ parts = object_key.to_s.split("/")
376
+ parts.empty? ? "" : parts.last
377
+ end
378
+
379
+ def to_job_status(data)
380
+ JobStatus.new(
381
+ status: data["status"],
382
+ presigned_url: data["presigned_url"].is_a?(String) ? data["presigned_url"] : nil,
383
+ object_key: data["object_key"].is_a?(String) ? data["object_key"] : nil,
384
+ error: data["error"].is_a?(String) ? data["error"] : nil
385
+ )
386
+ end
387
+
388
+ def to_batch_submission(data)
389
+ BatchSubmission.new(
390
+ batch_id: data["batch_id"].to_s,
391
+ status: data["status"].is_a?(String) ? data["status"] : "processing",
392
+ url_count: data["url_count"].is_a?(Numeric) ? data["url_count"] : 0,
393
+ total_discovered: data["total_discovered"].is_a?(Numeric) ? data["total_discovered"] : nil,
394
+ discovery_method: data["discovery_method"].is_a?(String) ? data["discovery_method"] : nil,
395
+ output_format: data["output_format"].is_a?(String) ? data["output_format"] : nil
396
+ )
397
+ end
398
+
399
+ def to_batch_status(d)
400
+ raw_items = d["items"].is_a?(Array) ? d["items"] : []
401
+ BatchStatus.new(
402
+ batch_id: d["batch_id"].to_s,
403
+ status: d["status"],
404
+ total: d["total"].is_a?(Numeric) ? d["total"] : 0,
405
+ completed: d["completed"].is_a?(Numeric) ? d["completed"] : 0,
406
+ failed: d["failed"].is_a?(Numeric) ? d["failed"] : 0,
407
+ in_progress: d["in_progress"].is_a?(Numeric) ? d["in_progress"] : 0,
408
+ output_mode: d["output_mode"],
409
+ zip_download_url: d["zip_download_url"].is_a?(String) ? d["zip_download_url"] : nil,
410
+ items: raw_items.map do |item|
411
+ BatchItem.new(
412
+ source_url: item["source_url"].is_a?(String) ? item["source_url"] : "",
413
+ status: item["status"].is_a?(String) ? item["status"] : "",
414
+ download_url: item["download_url"].is_a?(String) ? item["download_url"] : nil,
415
+ output_file_size: item["output_file_size"].is_a?(Numeric) ? item["output_file_size"] : nil,
416
+ duration: item["duration"].is_a?(String) ? item["duration"] : nil
417
+ )
418
+ end
419
+ )
420
+ end
421
+ end
422
+ end