enconvert 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +308 -0
- data/enconvert.gemspec +29 -0
- data/lib/enconvert/client.rb +419 -0
- data/lib/enconvert/errors.rb +41 -0
- data/lib/enconvert/formats.rb +195 -0
- data/lib/enconvert/internal.rb +153 -0
- data/lib/enconvert/results.rb +31 -0
- data/lib/enconvert/v2.rb +688 -0
- data/lib/enconvert/v2_results.rb +130 -0
- data/lib/enconvert/version.rb +5 -0
- data/lib/enconvert.rb +30 -0
- metadata +63 -0
|
@@ -0,0 +1,419 @@
|
|
|
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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Enconvert SDK exceptions.
|
|
4
|
+
module Enconvert
|
|
5
|
+
# Base class for every error raised by this SDK (including client-side
|
|
6
|
+
# validation errors raised before any HTTP request is made).
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised for any HTTP response with status >= 400 that isn't one of the
|
|
10
|
+
# more specific subclasses below. `status_code` carries the HTTP status.
|
|
11
|
+
class APIError < Error
|
|
12
|
+
attr_reader :status_code
|
|
13
|
+
|
|
14
|
+
def initialize(status_code, message)
|
|
15
|
+
@status_code = status_code
|
|
16
|
+
super("[#{status_code}] #{message}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# HTTP 401 or 403 — invalid or missing API key.
|
|
21
|
+
class AuthenticationError < APIError
|
|
22
|
+
def initialize(message = "Invalid or missing API key")
|
|
23
|
+
super(401, message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# HTTP 429 — too many requests.
|
|
28
|
+
class RateLimitError < APIError
|
|
29
|
+
def initialize(message = "Rate limit exceeded")
|
|
30
|
+
super(429, message)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# HTTP 402 — plan feature not enabled or monthly quota exhausted.
|
|
35
|
+
# V2 endpoints raise this for disabled/quota-gated features.
|
|
36
|
+
class QuotaError < APIError
|
|
37
|
+
def initialize(message = "Plan feature not enabled or quota exhausted")
|
|
38
|
+
super(402, message)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Enconvert
|
|
6
|
+
# Format tables mirroring the gateway's CONVERTER_MAP (api/v1/convert.py).
|
|
7
|
+
#
|
|
8
|
+
# IMPLEMENTED_CONVERSIONS is the client-side gate: the gateway returns 503
|
|
9
|
+
# for any `{input}-to-{output}` endpoint not in its CONVERTER_MAP, so we
|
|
10
|
+
# reject unsupported pairs here with a useful message instead of paying a
|
|
11
|
+
# network round-trip for a guaranteed failure.
|
|
12
|
+
module Formats
|
|
13
|
+
# The 43 implemented `{input}-to-{output}` conversion endpoints.
|
|
14
|
+
IMPLEMENTED_CONVERSIONS = Set.new(
|
|
15
|
+
[
|
|
16
|
+
# Structured text (13)
|
|
17
|
+
"json-to-xml",
|
|
18
|
+
"xml-to-json",
|
|
19
|
+
"json-to-yaml",
|
|
20
|
+
"yaml-to-json",
|
|
21
|
+
"csv-to-json",
|
|
22
|
+
"json-to-csv",
|
|
23
|
+
"json-to-toml",
|
|
24
|
+
"toml-to-json",
|
|
25
|
+
"csv-to-xml",
|
|
26
|
+
"xml-to-csv",
|
|
27
|
+
"markdown-to-html",
|
|
28
|
+
"markdown-to-pdf",
|
|
29
|
+
"html-to-pdf",
|
|
30
|
+
# Documents (9) — EPUB→PDF now flows through anything-to-pdf, not a dedicated pair.
|
|
31
|
+
"doc-to-pdf",
|
|
32
|
+
"excel-to-pdf",
|
|
33
|
+
"ppt-to-pdf",
|
|
34
|
+
"odt-to-pdf",
|
|
35
|
+
"ods-to-pdf",
|
|
36
|
+
"odp-to-pdf",
|
|
37
|
+
"ots-to-pdf",
|
|
38
|
+
"pages-to-pdf",
|
|
39
|
+
"numbers-to-pdf",
|
|
40
|
+
# Images (21)
|
|
41
|
+
"jpeg-to-png",
|
|
42
|
+
"png-to-jpeg",
|
|
43
|
+
"jpeg-to-svg",
|
|
44
|
+
"svg-to-jpeg",
|
|
45
|
+
"jpeg-to-heic",
|
|
46
|
+
"heic-to-jpeg",
|
|
47
|
+
"jpeg-to-webp",
|
|
48
|
+
"webp-to-jpeg",
|
|
49
|
+
"png-to-svg",
|
|
50
|
+
"svg-to-png",
|
|
51
|
+
"png-to-heic",
|
|
52
|
+
"heic-to-png",
|
|
53
|
+
"png-to-webp",
|
|
54
|
+
"webp-to-png",
|
|
55
|
+
"svg-to-heic",
|
|
56
|
+
"heic-to-svg",
|
|
57
|
+
"svg-to-webp",
|
|
58
|
+
"webp-to-svg",
|
|
59
|
+
"heic-to-webp",
|
|
60
|
+
"webp-to-heic",
|
|
61
|
+
"pdf-to-jpeg"
|
|
62
|
+
]
|
|
63
|
+
).freeze
|
|
64
|
+
|
|
65
|
+
# Extension -> API format name (input side).
|
|
66
|
+
IMAGE_FORMATS = {
|
|
67
|
+
".jpg" => "jpeg",
|
|
68
|
+
".jpeg" => "jpeg",
|
|
69
|
+
".png" => "png",
|
|
70
|
+
".svg" => "svg",
|
|
71
|
+
".heic" => "heic",
|
|
72
|
+
".webp" => "webp",
|
|
73
|
+
# PDF is an image input solely for pdf-to-jpeg (rasterization).
|
|
74
|
+
".pdf" => "pdf"
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
DOCUMENT_FORMATS = {
|
|
78
|
+
".doc" => "doc",
|
|
79
|
+
".docx" => "doc",
|
|
80
|
+
".xls" => "excel",
|
|
81
|
+
".xlsx" => "excel",
|
|
82
|
+
".ppt" => "ppt",
|
|
83
|
+
".pptx" => "ppt",
|
|
84
|
+
".html" => "html",
|
|
85
|
+
".htm" => "html",
|
|
86
|
+
".odt" => "odt",
|
|
87
|
+
".ods" => "ods",
|
|
88
|
+
".odp" => "odp",
|
|
89
|
+
".ots" => "ots",
|
|
90
|
+
".pages" => "pages",
|
|
91
|
+
".numbers" => "numbers",
|
|
92
|
+
# .epub has no dedicated document pair — use convert_to_pdf / convert_to_markdown.
|
|
93
|
+
".md" => "markdown",
|
|
94
|
+
".markdown" => "markdown",
|
|
95
|
+
".csv" => "csv",
|
|
96
|
+
".json" => "json",
|
|
97
|
+
".xml" => "xml",
|
|
98
|
+
".yaml" => "yaml",
|
|
99
|
+
".yml" => "yaml",
|
|
100
|
+
".toml" => "toml"
|
|
101
|
+
}.freeze
|
|
102
|
+
|
|
103
|
+
MIME_BY_EXT = {
|
|
104
|
+
".jpg" => "image/jpeg",
|
|
105
|
+
".jpeg" => "image/jpeg",
|
|
106
|
+
".png" => "image/png",
|
|
107
|
+
".svg" => "image/svg+xml",
|
|
108
|
+
".heic" => "image/heic",
|
|
109
|
+
".webp" => "image/webp",
|
|
110
|
+
".pdf" => "application/pdf",
|
|
111
|
+
".doc" => "application/msword",
|
|
112
|
+
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
113
|
+
".xls" => "application/vnd.ms-excel",
|
|
114
|
+
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
115
|
+
".ppt" => "application/vnd.ms-powerpoint",
|
|
116
|
+
".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
117
|
+
".html" => "text/html",
|
|
118
|
+
".htm" => "text/html",
|
|
119
|
+
".odt" => "application/vnd.oasis.opendocument.text",
|
|
120
|
+
".ods" => "application/vnd.oasis.opendocument.spreadsheet",
|
|
121
|
+
".odp" => "application/vnd.oasis.opendocument.presentation",
|
|
122
|
+
".epub" => "application/epub+zip",
|
|
123
|
+
".md" => "text/markdown",
|
|
124
|
+
".markdown" => "text/markdown",
|
|
125
|
+
".csv" => "text/csv",
|
|
126
|
+
".json" => "application/json",
|
|
127
|
+
".xml" => "application/xml",
|
|
128
|
+
".yaml" => "application/x-yaml",
|
|
129
|
+
".yml" => "application/x-yaml",
|
|
130
|
+
".toml" => "application/toml"
|
|
131
|
+
}.freeze
|
|
132
|
+
|
|
133
|
+
# Common aliases users pass that differ from the API's canonical format names.
|
|
134
|
+
OUTPUT_FORMAT_ALIASES = {
|
|
135
|
+
"jpg" => "jpeg",
|
|
136
|
+
"yml" => "yaml",
|
|
137
|
+
"htm" => "html",
|
|
138
|
+
"md" => "markdown"
|
|
139
|
+
}.freeze
|
|
140
|
+
|
|
141
|
+
module_function
|
|
142
|
+
|
|
143
|
+
def ext_of(name)
|
|
144
|
+
s = name.to_s
|
|
145
|
+
i = s.rindex(".")
|
|
146
|
+
i.nil? ? "" : s[i..].downcase
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def mime_for(name)
|
|
150
|
+
MIME_BY_EXT[ext_of(name)] || "application/octet-stream"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Map a filename's extension to its API input format, or raise.
|
|
154
|
+
def resolve_input_format(name, map)
|
|
155
|
+
ext = ext_of(name)
|
|
156
|
+
fmt = map[ext]
|
|
157
|
+
if fmt.nil?
|
|
158
|
+
supported = map.keys.sort.join(", ")
|
|
159
|
+
raise Enconvert::Error, "Unsupported file extension '#{ext}'. Supported: #{supported}"
|
|
160
|
+
end
|
|
161
|
+
fmt
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Lowercase, strip a leading dot, and resolve aliases (jpg, yml, htm, md).
|
|
165
|
+
def normalize_output_format(fmt)
|
|
166
|
+
f = fmt.to_s.downcase.sub(/\A\./, "")
|
|
167
|
+
OUTPUT_FORMAT_ALIASES[f] || f
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# List the output formats the API implements for a given input format.
|
|
171
|
+
def valid_outputs_for(input_format)
|
|
172
|
+
prefix = "#{input_format}-to-"
|
|
173
|
+
IMPLEMENTED_CONVERSIONS.to_a
|
|
174
|
+
.select { |name| name.start_with?(prefix) }
|
|
175
|
+
.map { |name| name[prefix.length..] }
|
|
176
|
+
.sort
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Assert `{input}-to-{output}` is an implemented endpoint and return its
|
|
180
|
+
# name. Raises with the list of valid outputs for that input otherwise.
|
|
181
|
+
def assert_conversion_implemented(input_format, output_format)
|
|
182
|
+
endpoint = "#{input_format}-to-#{output_format}"
|
|
183
|
+
unless IMPLEMENTED_CONVERSIONS.include?(endpoint)
|
|
184
|
+
outputs = valid_outputs_for(input_format)
|
|
185
|
+
hint = if outputs.empty?
|
|
186
|
+
"No conversions are available for input format '#{input_format}'"
|
|
187
|
+
else
|
|
188
|
+
"Supported outputs for '#{input_format}': #{outputs.join(', ')}"
|
|
189
|
+
end
|
|
190
|
+
raise Enconvert::Error, "Conversion '#{input_format}' to '#{output_format}' is not supported. #{hint}."
|
|
191
|
+
end
|
|
192
|
+
endpoint
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|