@websitescreenshotworld/sdk 0.0.0-stage → 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.
- package/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +399 -2
- package/dist/cjs/auto-schedule.d.ts +20 -0
- package/dist/cjs/auto-schedule.js +34 -0
- package/dist/cjs/client.d.ts +41 -0
- package/dist/cjs/client.js +58 -0
- package/dist/cjs/error-codes.d.ts +54 -0
- package/dist/cjs/error-codes.js +94 -0
- package/dist/cjs/errors.d.ts +56 -0
- package/dist/cjs/errors.js +112 -0
- package/dist/cjs/files.d.ts +6 -0
- package/dist/cjs/files.js +45 -0
- package/dist/cjs/index.d.ts +16 -0
- package/dist/cjs/index.js +37 -0
- package/dist/cjs/instant-snap.d.ts +99 -0
- package/dist/cjs/instant-snap.js +205 -0
- package/dist/cjs/models/capture-file.d.ts +35 -0
- package/dist/cjs/models/capture-file.js +34 -0
- package/dist/cjs/models/capture-options.d.ts +144 -0
- package/dist/cjs/models/capture-options.js +2 -0
- package/dist/cjs/models/capture.d.ts +108 -0
- package/dist/cjs/models/capture.js +63 -0
- package/dist/cjs/models/defaults.d.ts +82 -0
- package/dist/cjs/models/defaults.js +46 -0
- package/dist/cjs/models/tokens.d.ts +92 -0
- package/dist/cjs/models/tokens.js +85 -0
- package/dist/cjs/models/trigger-result.d.ts +20 -0
- package/dist/cjs/models/trigger-result.js +13 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/transport.d.ts +55 -0
- package/dist/cjs/transport.js +206 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/webhooks/events.d.ts +128 -0
- package/dist/cjs/webhooks/events.js +13 -0
- package/dist/cjs/webhooks/parse-event.d.ts +10 -0
- package/dist/cjs/webhooks/parse-event.js +149 -0
- package/dist/cjs/webhooks/webhook.d.ts +26 -0
- package/dist/cjs/webhooks/webhook.js +78 -0
- package/dist/cjs/wire.d.ts +15 -0
- package/dist/cjs/wire.js +71 -0
- package/dist/esm/auto-schedule.d.ts +20 -0
- package/dist/esm/auto-schedule.js +30 -0
- package/dist/esm/client.d.ts +41 -0
- package/dist/esm/client.js +54 -0
- package/dist/esm/error-codes.d.ts +54 -0
- package/dist/esm/error-codes.js +91 -0
- package/dist/esm/errors.d.ts +56 -0
- package/dist/esm/errors.js +98 -0
- package/dist/esm/files.d.ts +6 -0
- package/dist/esm/files.js +41 -0
- package/dist/esm/index.d.ts +16 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/instant-snap.d.ts +99 -0
- package/dist/esm/instant-snap.js +201 -0
- package/dist/esm/models/capture-file.d.ts +35 -0
- package/dist/esm/models/capture-file.js +31 -0
- package/dist/esm/models/capture-options.d.ts +144 -0
- package/dist/esm/models/capture-options.js +1 -0
- package/dist/esm/models/capture.d.ts +108 -0
- package/dist/esm/models/capture.js +57 -0
- package/dist/esm/models/defaults.d.ts +82 -0
- package/dist/esm/models/defaults.js +42 -0
- package/dist/esm/models/tokens.d.ts +92 -0
- package/dist/esm/models/tokens.js +82 -0
- package/dist/esm/models/trigger-result.d.ts +20 -0
- package/dist/esm/models/trigger-result.js +10 -0
- package/dist/esm/transport.d.ts +55 -0
- package/dist/esm/transport.js +201 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/webhooks/events.d.ts +128 -0
- package/dist/esm/webhooks/events.js +10 -0
- package/dist/esm/webhooks/parse-event.d.ts +10 -0
- package/dist/esm/webhooks/parse-event.js +146 -0
- package/dist/esm/webhooks/webhook.d.ts +26 -0
- package/dist/esm/webhooks/webhook.js +75 -0
- package/dist/esm/wire.d.ts +15 -0
- package/dist/esm/wire.js +66 -0
- package/package.json +68 -4
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ErrorCode } from "./error-codes.js";
|
|
2
|
+
/** What an error knows about the answer that caused it. */
|
|
3
|
+
export interface ErrorDetails {
|
|
4
|
+
errorCode?: ErrorCode | null;
|
|
5
|
+
httpStatus?: number | null;
|
|
6
|
+
retryAfterSeconds?: number | null;
|
|
7
|
+
rawBody?: string | null;
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The base class of every error this library throws. An `error_code` this version does not know
|
|
12
|
+
* raises this class itself; known codes raise one of its subclasses.
|
|
13
|
+
*/
|
|
14
|
+
export declare class WebsiteScreenshotWorldError extends Error {
|
|
15
|
+
/** The API's error token, such as `rate_limited`. `null` when no answer came back. */
|
|
16
|
+
readonly errorCode: ErrorCode | null;
|
|
17
|
+
/** The HTTP status of the answer. `null` when no answer came back. */
|
|
18
|
+
readonly httpStatus: number | null;
|
|
19
|
+
/** How long the API asked to wait before trying again, in seconds. */
|
|
20
|
+
readonly retryAfterSeconds: number | null;
|
|
21
|
+
/** The body of the answer as received. */
|
|
22
|
+
readonly rawBody: string | null;
|
|
23
|
+
constructor(message: string, details?: ErrorDetails);
|
|
24
|
+
}
|
|
25
|
+
/** The API key is missing, malformed, revoked or belongs to the other product. */
|
|
26
|
+
export declare class AuthenticationError extends WebsiteScreenshotWorldError {
|
|
27
|
+
}
|
|
28
|
+
/** The request was refused as invalid. Sending it again will not help. */
|
|
29
|
+
export declare class InvalidRequestError extends WebsiteScreenshotWorldError {
|
|
30
|
+
}
|
|
31
|
+
/** The plan has run out of captures, or does not include what was asked for. */
|
|
32
|
+
export declare class PlanLimitError extends WebsiteScreenshotWorldError {
|
|
33
|
+
}
|
|
34
|
+
/** The capture or web page does not exist for this account. */
|
|
35
|
+
export declare class NotFoundError extends WebsiteScreenshotWorldError {
|
|
36
|
+
}
|
|
37
|
+
/** Too many requests, still refused after the retries ran out. */
|
|
38
|
+
export declare class RateLimitError extends WebsiteScreenshotWorldError {
|
|
39
|
+
}
|
|
40
|
+
/** The website could not be captured, or the file was too large to return. Not retried. */
|
|
41
|
+
export declare class CaptureError extends WebsiteScreenshotWorldError {
|
|
42
|
+
}
|
|
43
|
+
/** The service was busy or failed on its side. */
|
|
44
|
+
export declare class ServiceError extends WebsiteScreenshotWorldError {
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The request could not be sent, no answer came back, or the call ran out of time. The underlying
|
|
48
|
+
* error, when there is one, is the `cause`.
|
|
49
|
+
*/
|
|
50
|
+
export declare class ConnectionError extends WebsiteScreenshotWorldError {
|
|
51
|
+
}
|
|
52
|
+
/** A webhook's signature header is missing, malformed, too old or does not match the body. */
|
|
53
|
+
export declare class SignatureVerificationError extends WebsiteScreenshotWorldError {
|
|
54
|
+
}
|
|
55
|
+
/** Creates the error class that `errorCode` raises. */
|
|
56
|
+
export declare function errorFor(errorCode: string, message: string, details: Omit<ErrorDetails, "errorCode">): WebsiteScreenshotWorldError;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ERROR_KINDS } from "./error-codes.js";
|
|
2
|
+
/**
|
|
3
|
+
* The base class of every error this library throws. An `error_code` this version does not know
|
|
4
|
+
* raises this class itself; known codes raise one of its subclasses.
|
|
5
|
+
*/
|
|
6
|
+
export class WebsiteScreenshotWorldError extends Error {
|
|
7
|
+
static {
|
|
8
|
+
this.prototype.name = "WebsiteScreenshotWorldError";
|
|
9
|
+
}
|
|
10
|
+
/** The API's error token, such as `rate_limited`. `null` when no answer came back. */
|
|
11
|
+
errorCode;
|
|
12
|
+
/** The HTTP status of the answer. `null` when no answer came back. */
|
|
13
|
+
httpStatus;
|
|
14
|
+
/** How long the API asked to wait before trying again, in seconds. */
|
|
15
|
+
retryAfterSeconds;
|
|
16
|
+
/** The body of the answer as received. */
|
|
17
|
+
rawBody;
|
|
18
|
+
constructor(message, details = {}) {
|
|
19
|
+
super(message, details.cause === undefined ? undefined : { cause: details.cause });
|
|
20
|
+
this.errorCode = details.errorCode ?? null;
|
|
21
|
+
this.httpStatus = details.httpStatus ?? null;
|
|
22
|
+
this.retryAfterSeconds = details.retryAfterSeconds ?? null;
|
|
23
|
+
this.rawBody = details.rawBody ?? null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** The API key is missing, malformed, revoked or belongs to the other product. */
|
|
27
|
+
export class AuthenticationError extends WebsiteScreenshotWorldError {
|
|
28
|
+
static {
|
|
29
|
+
this.prototype.name = "AuthenticationError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The request was refused as invalid. Sending it again will not help. */
|
|
33
|
+
export class InvalidRequestError extends WebsiteScreenshotWorldError {
|
|
34
|
+
static {
|
|
35
|
+
this.prototype.name = "InvalidRequestError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The plan has run out of captures, or does not include what was asked for. */
|
|
39
|
+
export class PlanLimitError extends WebsiteScreenshotWorldError {
|
|
40
|
+
static {
|
|
41
|
+
this.prototype.name = "PlanLimitError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The capture or web page does not exist for this account. */
|
|
45
|
+
export class NotFoundError extends WebsiteScreenshotWorldError {
|
|
46
|
+
static {
|
|
47
|
+
this.prototype.name = "NotFoundError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Too many requests, still refused after the retries ran out. */
|
|
51
|
+
export class RateLimitError extends WebsiteScreenshotWorldError {
|
|
52
|
+
static {
|
|
53
|
+
this.prototype.name = "RateLimitError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The website could not be captured, or the file was too large to return. Not retried. */
|
|
57
|
+
export class CaptureError extends WebsiteScreenshotWorldError {
|
|
58
|
+
static {
|
|
59
|
+
this.prototype.name = "CaptureError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The service was busy or failed on its side. */
|
|
63
|
+
export class ServiceError extends WebsiteScreenshotWorldError {
|
|
64
|
+
static {
|
|
65
|
+
this.prototype.name = "ServiceError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The request could not be sent, no answer came back, or the call ran out of time. The underlying
|
|
70
|
+
* error, when there is one, is the `cause`.
|
|
71
|
+
*/
|
|
72
|
+
export class ConnectionError extends WebsiteScreenshotWorldError {
|
|
73
|
+
static {
|
|
74
|
+
this.prototype.name = "ConnectionError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** A webhook's signature header is missing, malformed, too old or does not match the body. */
|
|
78
|
+
export class SignatureVerificationError extends WebsiteScreenshotWorldError {
|
|
79
|
+
static {
|
|
80
|
+
this.prototype.name = "SignatureVerificationError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const CLASS_BY_KIND = {
|
|
84
|
+
authentication: AuthenticationError,
|
|
85
|
+
invalid_request: InvalidRequestError,
|
|
86
|
+
plan_limit: PlanLimitError,
|
|
87
|
+
not_found: NotFoundError,
|
|
88
|
+
rate_limit: RateLimitError,
|
|
89
|
+
capture: CaptureError,
|
|
90
|
+
service: ServiceError,
|
|
91
|
+
};
|
|
92
|
+
const KIND_BY_CODE = new Map(Object.entries(ERROR_KINDS).flatMap(([kind, codes]) => codes.map((code) => [code, kind])));
|
|
93
|
+
/** Creates the error class that `errorCode` raises. */
|
|
94
|
+
export function errorFor(errorCode, message, details) {
|
|
95
|
+
const kind = KIND_BY_CODE.get(errorCode);
|
|
96
|
+
const ErrorClass = kind === undefined ? WebsiteScreenshotWorldError : CLASS_BY_KIND[kind];
|
|
97
|
+
return new ErrorClass(message, { ...details, errorCode });
|
|
98
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Where a file is written: a path, a Node.js writable stream or a web `WritableStream`. */
|
|
2
|
+
export type FileDestination = string | NodeJS.WritableStream | WritableStream<Uint8Array>;
|
|
3
|
+
/** Streams `body` to `destination`. A file left half-written by a failure is removed. */
|
|
4
|
+
export declare function writeBody(body: ReadableStream<Uint8Array>, destination: FileDestination, signal?: AbortSignal): Promise<void>;
|
|
5
|
+
/** The file name from a `Content-Disposition` header, preferring the RFC 6266 `filename*` form. */
|
|
6
|
+
export declare function filenameFrom(contentDisposition: string | null): string | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
/** Streams `body` to `destination`. A file left half-written by a failure is removed. */
|
|
6
|
+
export async function writeBody(body, destination, signal) {
|
|
7
|
+
const options = signal === undefined ? {} : { signal };
|
|
8
|
+
if (destination instanceof WritableStream) {
|
|
9
|
+
await body.pipeTo(destination, options);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const source = Readable.fromWeb(body);
|
|
13
|
+
if (typeof destination !== "string") {
|
|
14
|
+
await pipeline(source, destination, options);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
await pipeline(source, createWriteStream(destination), options);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
await rm(destination, { force: true });
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The file name from a `Content-Disposition` header, preferring the RFC 6266 `filename*` form. */
|
|
26
|
+
export function filenameFrom(contentDisposition) {
|
|
27
|
+
if (contentDisposition === null)
|
|
28
|
+
return null;
|
|
29
|
+
const extended = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(contentDisposition);
|
|
30
|
+
if (extended?.[2] !== undefined) {
|
|
31
|
+
try {
|
|
32
|
+
return decodeURIComponent(extended[2].trim());
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// A malformed percent-encoding: the plain form below may still hold a usable name.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const plain = /filename\s*=\s*(?:"((?:\\.|[^"\\])*)"|([^;]+))/i.exec(contentDisposition);
|
|
39
|
+
const name = plain?.[1]?.replace(/\\(.)/g, "$1") ?? plain?.[2]?.trim();
|
|
40
|
+
return name === undefined || name === "" ? null : name;
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { WebsiteScreenshotWorld, type ClientOptions } from "./client.js";
|
|
2
|
+
export type { InstantSnap, WaitOptions } from "./instant-snap.js";
|
|
3
|
+
export type { AutoSchedule } from "./auto-schedule.js";
|
|
4
|
+
export type { CaptureRequestOptions, RequestOptions } from "./transport.js";
|
|
5
|
+
export type { FileDestination } from "./files.js";
|
|
6
|
+
export type { CaptureFileOptions, CaptureOptions, CaptureToBucketOptions, GeolocationOptions, PageSourceOptions, ProxyOptions, S3DeliveryOptions, SelectorOptions, TimestampWatermarkOptions, ViewportOptions, } from "./models/capture-options.js";
|
|
7
|
+
export type { Capture, CaptureFields, PageSource, S3Delivery, } from "./models/capture.js";
|
|
8
|
+
export type { CaptureFile } from "./models/capture-file.js";
|
|
9
|
+
export type { Catalog, DefaultViewport, Defaults } from "./models/defaults.js";
|
|
10
|
+
export type { TriggerResult } from "./models/trigger-result.js";
|
|
11
|
+
export { CaptureFormat, CaptureStatus, DeviceCategory, MediaCategory, PageSourceFormat, Product, ResponseType, S3DeliveryStatus, ScrollMode, TriggeredBy, TriggerStatus, WatermarkLocation, } from "./models/tokens.js";
|
|
12
|
+
export { ErrorCode } from "./error-codes.js";
|
|
13
|
+
export { AuthenticationError, CaptureError, ConnectionError, InvalidRequestError, NotFoundError, PlanLimitError, RateLimitError, ServiceError, SignatureVerificationError, WebsiteScreenshotWorldError, type ErrorDetails, } from "./errors.js";
|
|
14
|
+
export { Webhook, type VerifyOptions } from "./webhooks/webhook.js";
|
|
15
|
+
export { WebhookEventType, type AutoScheduleCaptureEvent, type CaptureChangedData, type CaptureChangedEvent, type DeliveryCompletedData, type DeliveryCompletedEvent, type Destination, type InstantSnapCaptureData, type InstantSnapCaptureEvent, type PingData, type PingEvent, type ScheduledCapture, type SchedulePausedData, type SchedulePausedEvent, type SitemapRunCompletedData, type SitemapRunCompletedEvent, type UnknownWebhookEvent, type WebhookEvent, } from "./webhooks/events.js";
|
|
16
|
+
export { VERSION } from "./version.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { WebsiteScreenshotWorld } from "./client.js";
|
|
2
|
+
export { CaptureFormat, CaptureStatus, DeviceCategory, MediaCategory, PageSourceFormat, Product, ResponseType, S3DeliveryStatus, ScrollMode, TriggeredBy, TriggerStatus, WatermarkLocation, } from "./models/tokens.js";
|
|
3
|
+
export { ErrorCode } from "./error-codes.js";
|
|
4
|
+
export { AuthenticationError, CaptureError, ConnectionError, InvalidRequestError, NotFoundError, PlanLimitError, RateLimitError, ServiceError, SignatureVerificationError, WebsiteScreenshotWorldError, } from "./errors.js";
|
|
5
|
+
export { Webhook } from "./webhooks/webhook.js";
|
|
6
|
+
export { WebhookEventType, } from "./webhooks/events.js";
|
|
7
|
+
export { VERSION } from "./version.js";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type FileDestination } from "./files.js";
|
|
2
|
+
import { type CaptureFile } from "./models/capture-file.js";
|
|
3
|
+
import type { CaptureFileOptions, CaptureOptions, CaptureToBucketOptions } from "./models/capture-options.js";
|
|
4
|
+
import { type Capture } from "./models/capture.js";
|
|
5
|
+
import { type Catalog, type Defaults } from "./models/defaults.js";
|
|
6
|
+
import type { CaptureRequestOptions, RequestOptions, Transport } from "./transport.js";
|
|
7
|
+
/** Options for {@link InstantSnap.wait}. */
|
|
8
|
+
export interface WaitOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Seconds to keep reading the capture. When they run out, `wait` returns the capture as last
|
|
11
|
+
* read, still unfinished, rather than throwing. Default 360.
|
|
12
|
+
*/
|
|
13
|
+
timeoutSeconds?: number;
|
|
14
|
+
/** Seconds between reads. Default 3; `0` reads again at once. */
|
|
15
|
+
pollIntervalSeconds?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Stops waiting, and the read in progress, as soon as it aborts; `wait` then rejects with the
|
|
18
|
+
* signal's reason.
|
|
19
|
+
*/
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
/** What {@link InstantSnap} needs from the client. */
|
|
23
|
+
export interface InstantSnapConfig {
|
|
24
|
+
transport: Transport;
|
|
25
|
+
apiKey: string | undefined;
|
|
26
|
+
baseUrl: string;
|
|
27
|
+
timeout: number;
|
|
28
|
+
captureTimeout: number;
|
|
29
|
+
}
|
|
30
|
+
/** On-demand captures: an image, a video or a PDF of a web page. */
|
|
31
|
+
export declare class InstantSnap {
|
|
32
|
+
#private;
|
|
33
|
+
/**
|
|
34
|
+
* Captures a page and returns the capture, with a `downloadUrl` valid for 60 minutes.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const capture = await client.instantSnap.capture({ url: "https://example.com", format: "webp" });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
capture(options: CaptureOptions, requestOptions?: CaptureRequestOptions): Promise<Capture>;
|
|
42
|
+
/**
|
|
43
|
+
* Captures a page and returns the file itself as a stream, with its metadata. The API does not
|
|
44
|
+
* keep the file, so read or save it before discarding the result.
|
|
45
|
+
*/
|
|
46
|
+
captureFile(options: CaptureFileOptions, requestOptions?: CaptureRequestOptions): Promise<CaptureFile>;
|
|
47
|
+
/** Captures a page and uploads the file to your S3-compatible bucket. */
|
|
48
|
+
captureToBucket(options: CaptureToBucketOptions, requestOptions?: CaptureRequestOptions): Promise<Capture>;
|
|
49
|
+
/**
|
|
50
|
+
* Queues a capture and returns at once with it, `status` `queued`; it runs when a browser is
|
|
51
|
+
* free. Read it with {@link InstantSnap.wait} or {@link InstantSnap.get}, or receive it at
|
|
52
|
+
* `callbackUrl`.
|
|
53
|
+
*
|
|
54
|
+
* `options.timeout` sets the browser time it may take, 30 to 300 seconds. The call itself uses
|
|
55
|
+
* the client's `timeout`, not `captureTimeout`, since nothing is captured while it waits. A
|
|
56
|
+
* capture still queued 15 minutes later ends `failed` with `capture_expired`, not charged.
|
|
57
|
+
*
|
|
58
|
+
* @throws `RateLimitError` with `errorCode` `queue_full` when too many async captures are
|
|
59
|
+
* already waiting or running. It is not retried; `retryAfterSeconds` says when to try again.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const queued = await client.instantSnap.submit({ url: "https://example.com", timeout: 240 });
|
|
64
|
+
* const capture = await client.instantSnap.wait(queued.id);
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
submit(options: CaptureOptions, requestOptions?: CaptureRequestOptions): Promise<Capture>;
|
|
68
|
+
/**
|
|
69
|
+
* Queues a capture that uploads the file to your S3-compatible bucket, and returns at once with
|
|
70
|
+
* it, `status` `queued`. Otherwise as {@link InstantSnap.submit}.
|
|
71
|
+
*/
|
|
72
|
+
submitToBucket(options: CaptureToBucketOptions, requestOptions?: CaptureRequestOptions): Promise<Capture>;
|
|
73
|
+
/**
|
|
74
|
+
* Reads a capture with `get` every `pollIntervalSeconds` until its status is no longer `queued`,
|
|
75
|
+
* `running` or `pending`, and returns it. When `timeoutSeconds` runs out first, returns the
|
|
76
|
+
* capture as last read, still unfinished, so you can decide whether to wait again. An error from
|
|
77
|
+
* `get` is thrown as it is.
|
|
78
|
+
*
|
|
79
|
+
* @param capture - A capture's id, or the capture itself, such as the one `submit` returned.
|
|
80
|
+
*/
|
|
81
|
+
wait(capture: string | {
|
|
82
|
+
id: string;
|
|
83
|
+
}, options?: WaitOptions): Promise<Capture>;
|
|
84
|
+
/** Reads a capture back, with a fresh `downloadUrl`. */
|
|
85
|
+
get(id: string, requestOptions?: RequestOptions): Promise<Capture>;
|
|
86
|
+
/**
|
|
87
|
+
* Downloads a capture's file, or its page source, to a path or a stream.
|
|
88
|
+
*
|
|
89
|
+
* @param source - A capture from `capture` or `get`, or its `pageSource`.
|
|
90
|
+
* @param destination - A file path, a Node.js writable stream or a web `WritableStream`.
|
|
91
|
+
*/
|
|
92
|
+
download(source: {
|
|
93
|
+
downloadUrl: string | null;
|
|
94
|
+
}, destination: FileDestination, requestOptions?: RequestOptions): Promise<void>;
|
|
95
|
+
/** The values a capture takes for fields it does not set, and the limits. Needs no API key. */
|
|
96
|
+
defaults(requestOptions?: RequestOptions): Promise<Defaults>;
|
|
97
|
+
/** The devices `emulatedDevice` accepts and the countries `proxy.country` accepts. */
|
|
98
|
+
catalog(requestOptions?: RequestOptions): Promise<Catalog>;
|
|
99
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { AuthenticationError } from "./errors.js";
|
|
3
|
+
import { writeBody } from "./files.js";
|
|
4
|
+
import { captureFileFrom } from "./models/capture-file.js";
|
|
5
|
+
import { parseCapture, } from "./models/capture.js";
|
|
6
|
+
import { parseCatalog, parseDefaults, } from "./models/defaults.js";
|
|
7
|
+
import { toWire } from "./wire.js";
|
|
8
|
+
const CAPTURES_PATH = "/instant-snap/v1/captures";
|
|
9
|
+
const DEFAULT_WAIT_TIMEOUT_SECONDS = 360;
|
|
10
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 3;
|
|
11
|
+
/** The statuses of a capture that has not finished yet. */
|
|
12
|
+
const UNFINISHED_STATUSES = new Set([
|
|
13
|
+
"queued",
|
|
14
|
+
"running",
|
|
15
|
+
"pending",
|
|
16
|
+
]);
|
|
17
|
+
/** On-demand captures: an image, a video or a PDF of a web page. */
|
|
18
|
+
export class InstantSnap {
|
|
19
|
+
#config;
|
|
20
|
+
/** @internal Use `client.instantSnap`. */
|
|
21
|
+
constructor(config) {
|
|
22
|
+
this.#config = config;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Captures a page and returns the capture, with a `downloadUrl` valid for 60 minutes.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* const capture = await client.instantSnap.capture({ url: "https://example.com", format: "webp" });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
async capture(options, requestOptions = {}) {
|
|
33
|
+
const wire = await this.#transport.json(this.#captureCall(options, "json", requestOptions));
|
|
34
|
+
return parseCapture(wire);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Captures a page and returns the file itself as a stream, with its metadata. The API does not
|
|
38
|
+
* keep the file, so read or save it before discarding the result.
|
|
39
|
+
*/
|
|
40
|
+
async captureFile(options, requestOptions = {}) {
|
|
41
|
+
const response = await this.#transport.file(this.#captureCall(options, "by_format", requestOptions));
|
|
42
|
+
return captureFileFrom(response);
|
|
43
|
+
}
|
|
44
|
+
/** Captures a page and uploads the file to your S3-compatible bucket. */
|
|
45
|
+
async captureToBucket(options, requestOptions = {}) {
|
|
46
|
+
const wire = await this.#transport.json(this.#captureCall(options, "cloud_storage", requestOptions));
|
|
47
|
+
return parseCapture(wire);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Queues a capture and returns at once with it, `status` `queued`; it runs when a browser is
|
|
51
|
+
* free. Read it with {@link InstantSnap.wait} or {@link InstantSnap.get}, or receive it at
|
|
52
|
+
* `callbackUrl`.
|
|
53
|
+
*
|
|
54
|
+
* `options.timeout` sets the browser time it may take, 30 to 300 seconds. The call itself uses
|
|
55
|
+
* the client's `timeout`, not `captureTimeout`, since nothing is captured while it waits. A
|
|
56
|
+
* capture still queued 15 minutes later ends `failed` with `capture_expired`, not charged.
|
|
57
|
+
*
|
|
58
|
+
* @throws `RateLimitError` with `errorCode` `queue_full` when too many async captures are
|
|
59
|
+
* already waiting or running. It is not retried; `retryAfterSeconds` says when to try again.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* const queued = await client.instantSnap.submit({ url: "https://example.com", timeout: 240 });
|
|
64
|
+
* const capture = await client.instantSnap.wait(queued.id);
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
async submit(options, requestOptions = {}) {
|
|
68
|
+
const wire = await this.#transport.json(this.#captureCall(options, "json", requestOptions, true));
|
|
69
|
+
return parseCapture(wire);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Queues a capture that uploads the file to your S3-compatible bucket, and returns at once with
|
|
73
|
+
* it, `status` `queued`. Otherwise as {@link InstantSnap.submit}.
|
|
74
|
+
*/
|
|
75
|
+
async submitToBucket(options, requestOptions = {}) {
|
|
76
|
+
const wire = await this.#transport.json(this.#captureCall(options, "cloud_storage", requestOptions, true));
|
|
77
|
+
return parseCapture(wire);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Reads a capture with `get` every `pollIntervalSeconds` until its status is no longer `queued`,
|
|
81
|
+
* `running` or `pending`, and returns it. When `timeoutSeconds` runs out first, returns the
|
|
82
|
+
* capture as last read, still unfinished, so you can decide whether to wait again. An error from
|
|
83
|
+
* `get` is thrown as it is.
|
|
84
|
+
*
|
|
85
|
+
* @param capture - A capture's id, or the capture itself, such as the one `submit` returned.
|
|
86
|
+
*/
|
|
87
|
+
async wait(capture, options = {}) {
|
|
88
|
+
const id = typeof capture === "string" ? capture : capture.id;
|
|
89
|
+
const timeoutMs = (options.timeoutSeconds ?? DEFAULT_WAIT_TIMEOUT_SECONDS) * 1000;
|
|
90
|
+
const intervalMs = (options.pollIntervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
|
|
91
|
+
const { signal } = options;
|
|
92
|
+
const expiresAt = performance.now() + timeoutMs;
|
|
93
|
+
for (;;) {
|
|
94
|
+
const current = await this.get(id, { signal });
|
|
95
|
+
const remaining = expiresAt - performance.now();
|
|
96
|
+
if (!UNFINISHED_STATUSES.has(current.status) || remaining <= 0) {
|
|
97
|
+
return current;
|
|
98
|
+
}
|
|
99
|
+
if (intervalMs > 0)
|
|
100
|
+
await sleep(Math.min(intervalMs, remaining), signal);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Reads a capture back, with a fresh `downloadUrl`. */
|
|
104
|
+
async get(id, requestOptions = {}) {
|
|
105
|
+
const wire = await this.#transport.json({
|
|
106
|
+
method: "GET",
|
|
107
|
+
url: this.#url(`${CAPTURES_PATH}/${encodeURIComponent(id)}`),
|
|
108
|
+
apiKey: this.#apiKey(),
|
|
109
|
+
timeout: requestOptions.timeout ?? this.#config.timeout,
|
|
110
|
+
signal: requestOptions.signal,
|
|
111
|
+
});
|
|
112
|
+
return parseCapture(wire);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Downloads a capture's file, or its page source, to a path or a stream.
|
|
116
|
+
*
|
|
117
|
+
* @param source - A capture from `capture` or `get`, or its `pageSource`.
|
|
118
|
+
* @param destination - A file path, a Node.js writable stream or a web `WritableStream`.
|
|
119
|
+
*/
|
|
120
|
+
async download(source, destination, requestOptions = {}) {
|
|
121
|
+
if (source.downloadUrl === null) {
|
|
122
|
+
throw new TypeError("This capture has no downloadUrl. Read it again with get() for a fresh one.");
|
|
123
|
+
}
|
|
124
|
+
// The link is signed, so the API key stays with the API and is not sent to the file host.
|
|
125
|
+
const response = await this.#transport.file({
|
|
126
|
+
method: "GET",
|
|
127
|
+
url: source.downloadUrl,
|
|
128
|
+
file: true,
|
|
129
|
+
timeout: requestOptions.timeout ?? this.#config.timeout,
|
|
130
|
+
signal: requestOptions.signal,
|
|
131
|
+
});
|
|
132
|
+
const body = response.body ?? new Blob([]).stream();
|
|
133
|
+
await writeBody(body, destination, requestOptions.signal);
|
|
134
|
+
}
|
|
135
|
+
/** The values a capture takes for fields it does not set, and the limits. Needs no API key. */
|
|
136
|
+
async defaults(requestOptions = {}) {
|
|
137
|
+
const wire = await this.#transport.json({
|
|
138
|
+
method: "GET",
|
|
139
|
+
url: this.#url("/instant-snap/v1/defaults"),
|
|
140
|
+
timeout: requestOptions.timeout ?? this.#config.timeout,
|
|
141
|
+
signal: requestOptions.signal,
|
|
142
|
+
});
|
|
143
|
+
return parseDefaults(wire);
|
|
144
|
+
}
|
|
145
|
+
/** The devices `emulatedDevice` accepts and the countries `proxy.country` accepts. */
|
|
146
|
+
async catalog(requestOptions = {}) {
|
|
147
|
+
const wire = await this.#transport.json({
|
|
148
|
+
method: "GET",
|
|
149
|
+
url: this.#url("/instant-snap/v1/catalog"),
|
|
150
|
+
apiKey: this.#apiKey(),
|
|
151
|
+
timeout: requestOptions.timeout ?? this.#config.timeout,
|
|
152
|
+
signal: requestOptions.signal,
|
|
153
|
+
});
|
|
154
|
+
return parseCatalog(wire);
|
|
155
|
+
}
|
|
156
|
+
get #transport() {
|
|
157
|
+
return this.#config.transport;
|
|
158
|
+
}
|
|
159
|
+
#captureCall(options, responseType, requestOptions, async = false) {
|
|
160
|
+
const body = { ...toWire(options), response_type: responseType };
|
|
161
|
+
return {
|
|
162
|
+
method: "POST",
|
|
163
|
+
url: this.#url(CAPTURES_PATH),
|
|
164
|
+
apiKey: this.#apiKey(),
|
|
165
|
+
body: async ? { ...body, async: true } : body,
|
|
166
|
+
idempotencyKey: requestOptions.idempotencyKey ?? randomUUID(),
|
|
167
|
+
file: responseType === "by_format",
|
|
168
|
+
// An async capture is answered at once, so it takes the ordinary timeout.
|
|
169
|
+
timeout: requestOptions.timeout ??
|
|
170
|
+
(async ? this.#config.timeout : this.#config.captureTimeout),
|
|
171
|
+
signal: requestOptions.signal,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
#url(path) {
|
|
175
|
+
return `${this.#config.baseUrl}${path}`;
|
|
176
|
+
}
|
|
177
|
+
#apiKey() {
|
|
178
|
+
if (this.#config.apiKey === undefined) {
|
|
179
|
+
throw new AuthenticationError("No Instant Snap API key: pass instantSnapApiKey to the client or set WSW_INSTANT_SNAP_API_KEY.");
|
|
180
|
+
}
|
|
181
|
+
return this.#config.apiKey;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Waits `milliseconds`, or rejects with the signal's reason as soon as it aborts. */
|
|
185
|
+
function sleep(milliseconds, signal) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
if (signal?.aborted === true) {
|
|
188
|
+
reject(signal.reason);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const onAbort = () => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
reject(signal?.reason);
|
|
194
|
+
};
|
|
195
|
+
const timer = setTimeout(() => {
|
|
196
|
+
signal?.removeEventListener("abort", onAbort);
|
|
197
|
+
resolve();
|
|
198
|
+
}, milliseconds);
|
|
199
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CaptureStatus } from "./tokens.js";
|
|
2
|
+
/**
|
|
3
|
+
* A file returned by `instantSnap.captureFile`, with its metadata. The body is a stream that can be
|
|
4
|
+
* read once: through `body`, `arrayBuffer()` or `saveTo()`.
|
|
5
|
+
*/
|
|
6
|
+
export interface CaptureFile {
|
|
7
|
+
id: string;
|
|
8
|
+
/** `delivered`: the file is in this answer and the API does not keep it. */
|
|
9
|
+
status: CaptureStatus;
|
|
10
|
+
contentType: string | null;
|
|
11
|
+
/** The name the API suggests, from `Content-Disposition`. */
|
|
12
|
+
filename: string | null;
|
|
13
|
+
width: number | null;
|
|
14
|
+
height: number | null;
|
|
15
|
+
sizeBytes: number | null;
|
|
16
|
+
/** How long the capture took. */
|
|
17
|
+
durationMs: number | null;
|
|
18
|
+
/** The status the website answered with. */
|
|
19
|
+
pageHttpStatus: number | null;
|
|
20
|
+
specVersion: string | null;
|
|
21
|
+
capturedAt: Date | null;
|
|
22
|
+
/** Where the browser ended up. `null` when the page did not redirect or the URL is not plain ASCII. */
|
|
23
|
+
finalUrl: string | null;
|
|
24
|
+
/** Lowercase hex SHA-256 of the file. */
|
|
25
|
+
sha256: string | null;
|
|
26
|
+
/** How many parts the answer has, when it has more than one. */
|
|
27
|
+
partCount: number | null;
|
|
28
|
+
/** The file itself. */
|
|
29
|
+
readonly body: ReadableStream<Uint8Array>;
|
|
30
|
+
/** Reads the whole file into memory. */
|
|
31
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
32
|
+
/** Streams the file to `path`, replacing any file already there. */
|
|
33
|
+
saveTo(path: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export declare function captureFileFrom(response: Response): CaptureFile;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { filenameFrom, writeBody } from "../files.js";
|
|
2
|
+
import { parseDate } from "../wire.js";
|
|
3
|
+
export function captureFileFrom(response) {
|
|
4
|
+
const headers = response.headers;
|
|
5
|
+
const header = (name) => headers.get(`x-instantsnap-${name}`);
|
|
6
|
+
const body = response.body ?? new Blob([]).stream();
|
|
7
|
+
return {
|
|
8
|
+
id: header("id") ?? "",
|
|
9
|
+
status: header("status") ?? "delivered",
|
|
10
|
+
contentType: headers.get("content-type"),
|
|
11
|
+
filename: filenameFrom(headers.get("content-disposition")),
|
|
12
|
+
width: integerOrNull(header("width")),
|
|
13
|
+
height: integerOrNull(header("height")),
|
|
14
|
+
sizeBytes: integerOrNull(header("size-bytes")),
|
|
15
|
+
durationMs: integerOrNull(header("duration-ms")),
|
|
16
|
+
pageHttpStatus: integerOrNull(header("page-http-status")),
|
|
17
|
+
specVersion: header("spec-version"),
|
|
18
|
+
capturedAt: parseDate(header("captured-at")),
|
|
19
|
+
finalUrl: header("final-url"),
|
|
20
|
+
sha256: header("sha256"),
|
|
21
|
+
partCount: integerOrNull(header("part-count")),
|
|
22
|
+
body,
|
|
23
|
+
arrayBuffer: () => new Response(body).arrayBuffer(),
|
|
24
|
+
saveTo: (path) => writeBody(body, path),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function integerOrNull(value) {
|
|
28
|
+
if (value === null || !/^\s*-?\d+\s*$/.test(value))
|
|
29
|
+
return null;
|
|
30
|
+
return Number(value);
|
|
31
|
+
}
|