import { CrawlerConfig, ExtractionConfig, ScrapeConfig, ScrapflyClient, ScreenshotConfig, type ResultData, } from "scrapfly-sdk" import * as z from "zod" import { defineAction } from "../../automation/actions" import { camelCaseScrapflyValue, getScrapflyClient } from "./lib" const SCRAPFLY_FORMAT_SCHEMA = z.enum([ "json", "text", "markdown", "clean_html", "raw", ]) const SCRAPFLY_SCREENSHOT_FLAG_SCHEMA = z.enum([ "load_images", "dark_mode", "block_banners", "print_media_format", "high_quality", ]) const SCRAPFLY_SCRAPE_OPTIONS_SCHEMA = z.object({ /** Enable automatic scrolling in the rendering browser. */ autoScroll: z.boolean().optional(), /** Enable Anti Scraping Protection. */ asp: z.boolean().optional(), /** Chromium brand used for browser fingerprint generation. */ browserBrand: z.enum(["chrome", "edge", "brave", "opera"]).optional(), /** Upstream request body. */ body: z.string().optional(), /** Read a matching response from Scrapfly's cache. */ cache: z.boolean().optional(), /** Remove a matching cache entry before scraping. */ cacheClear: z.boolean().optional(), /** Cache lifetime in seconds. */ cacheTtl: z.number().int().nonnegative().optional(), /** ISO 3166-1 alpha-2 proxy country or provider-supported country selector. */ country: z.string().min(1).optional(), /** Maximum API credits to spend on the request. */ costBudget: z.number().int().nonnegative().optional(), /** Correlation ID shown in Scrapfly logs. */ correlationId: z.string().min(1).optional(), /** Cookies sent to the target. */ cookies: z.record(z.string(), z.string()).optional(), /** Collect DNS diagnostics. */ dns: z.boolean().optional(), /** Saved extraction template name. */ extractionTemplate: z.string().min(1).optional(), /** Scrapfly automatic extraction model. */ extractionModel: z.string().min(1).optional(), /** Natural-language extraction instruction. */ extractionPrompt: z.string().min(1).optional(), /** Scraped content representation. */ format: SCRAPFLY_FORMAT_SCHEMA.optional(), /** Filters applied to the selected content representation. */ formatOptions: z .enum(["no_links", "no_images", "only_content"]) .array() .min(1) .optional(), /** Browser geolocation formatted as latitude,longitude. */ geolocation: z.string().min(1).optional(), /** Headers sent to the target. */ headers: z.record(z.string(), z.string()).optional(), /** JavaScript evaluated after the page loads. */ javascript: z.string().optional(), /** Browser scenario encoded by the official Scrapfly SDK. */ javascriptScenario: z.record(z.string(), z.json()).optional(), /** Preferred browser languages. */ languages: z.string().min(1).array().min(1).optional(), /** Upstream HTTP method. */ method: z.enum(["GET", "POST", "PUT", "PATCH"]).optional(), /** Browser operating system selector. */ os: z.string().min(1).optional(), /** Scrapfly proxy pool. */ proxyPool: z.string().min(1).optional(), /** Fail when the upstream target returns an error status. */ raiseOnUpstreamError: z.boolean().optional(), /** Enable JavaScript rendering. */ renderJs: z.boolean().optional(), /** Browser load milestone. */ renderingStage: z.enum(["complete", "domcontentloaded"]).optional(), /** Milliseconds to wait after page rendering. */ renderingWait: z.number().int().nonnegative().optional(), /** Reuse a Scrapfly session. */ session: z.string().min(1).optional(), /** Keep a session on one proxy identity. */ sessionStickyProxy: z.boolean().optional(), /** Named screenshots mapped to fullpage, CSS, or XPath selectors. */ screenshots: z.record(z.string().min(1), z.string().min(1)).optional(), /** Screenshot behavior flags. */ screenshotFlags: SCRAPFLY_SCREENSHOT_FLAG_SCHEMA.array().min(1).optional(), /** Collect TLS certificate diagnostics. */ ssl: z.boolean().optional(), /** Tags attached to Scrapfly monitoring logs. */ tags: z.string().min(1).array().min(1).optional(), /** Provider timeout in milliseconds. */ timeout: z.number().int().positive().optional(), /** Complete URL to scrape. */ url: z.url(), /** CSS or XPath selector that must appear before capture. */ waitForSelector: z.string().min(1).optional(), /** Dashboard-configured Scrapfly webhook name. */ webhookName: z.string().min(1).optional(), }) const SCRAPFLY_SCREENSHOT_SCHEMA = z.object({ cssSelector: z.string().nullable().optional(), extension: z.string(), format: z.string(), size: z.number().int().nonnegative(), url: z.url(), }) const SCRAPFLY_URI_SCHEMA = z.object({ baseUrl: z.string(), fragment: z.string().nullable().optional(), host: z.string(), params: z.record(z.string(), z.string()).nullable().optional(), port: z.number().int(), query: z.string().nullable().optional(), rootDomain: z.string(), scheme: z.string(), }) const SCRAPFLY_BROWSER_DATA_SCHEMA = z.object({ javascriptEvaluationResult: z.string().nullable().optional(), jsScenario: z .object({ duration: z.number().nonnegative(), executed: z.number().int().nonnegative(), response: z.json(), steps: z .object({ action: z.string(), config: z.record(z.string(), z.json()), duration: z.number().nonnegative(), executed: z.boolean(), result: z.string().optional(), success: z.boolean(), }) .array(), }) .optional(), localStorageData: z.record(z.string(), z.string()).optional(), sessionStorageData: z.record(z.string(), z.string()).optional(), websockets: z.json().array().optional(), xhrCall: z .object({ body: z.string().optional(), headers: z.record(z.string(), z.string()), method: z.string(), response: z.object({ body: z.string(), duration: z.number().nonnegative(), format: z.string(), headers: z.record(z.string(), z.string()), status: z.number().int(), }), type: z.string(), url: z.url(), }) .array() .optional(), }) const SCRAPFLY_COOKIE_SCHEMA = z.object({ comment: z.string(), domain: z.string(), expires: z.string(), httpOnly: z.boolean(), maxAge: z.number(), name: z.string(), path: z.string(), secure: z.boolean(), size: z.number().int().nonnegative(), value: z.string(), version: z.string(), }) const SCRAPFLY_SCRAPE_RESULT_SCHEMA = z.object({ browserData: SCRAPFLY_BROWSER_DATA_SCHEMA, content: z.string(), contentEncoding: z.string(), contentType: z.string(), context: z.object({ asp: z.boolean().nullable(), bandwidthConsumed: z.number().nonnegative().optional(), cache: z.object({ entry: z.string().nullable().optional(), state: z.string(), }), cookies: z.union([ z.record(z.string(), z.string()), z.record(z.string(), z.json()).array(), ]), cost: z.object({ details: z .object({ amount: z.number(), code: z.string(), description: z.string(), }) .array(), total: z.number(), }), createdAt: z.string(), debug: z .object({ responseUrl: z.string(), screenshotUrl: z.string().optional(), }) .nullable(), env: z.string(), fingerprint: z.string().optional(), headers: z.record(z.string(), z.string()), isXmlHttpRequest: z.boolean(), job: z.string().nullable().optional(), lang: z.union([z.string(), z.string().array()]), os: z.object({ distribution: z.string().optional(), name: z.string(), type: z.string().optional(), version: z.string(), }), project: z.string(), proxy: z.object({ country: z.string(), identity: z.string(), network: z.string(), pool: z.string(), }), redirects: z.string().array(), retry: z.number().int().nonnegative(), schedule: z.string().nullable().optional(), session: z.string().nullable().optional(), spider: z.json().optional(), throttler: z.json().optional(), uri: SCRAPFLY_URI_SCHEMA, url: z.url(), webhook: z.string().nullable().optional(), }), cookies: SCRAPFLY_COOKIE_SCHEMA.array(), data: z.json().optional(), dns: z.record(z.string(), z.record(z.string(), z.json()).array()).optional(), duration: z.number().nonnegative(), error: z .object({ code: z.string(), docUrl: z.string().optional(), httpCode: z.number().int(), links: z.record(z.string(), z.string()), message: z.string(), retryable: z.boolean(), }) .nullable() .optional(), format: z.string(), iframes: z .object({ content: z.string(), uri: SCRAPFLY_URI_SCHEMA, url: z.url() }) .array(), logUrl: z.url(), reason: z.string(), requestHeaders: z.record(z.string(), z.string()), responseHeaders: z.record(z.string(), z.string()), screenshots: z.record(z.string(), SCRAPFLY_SCREENSHOT_SCHEMA), size: z.number().int().nonnegative(), ssl: z .object({ certs: z.record(z.string(), z.json()).array() }) .nullable() .optional(), status: z.string(), statusCode: z.number().int(), success: z.boolean(), url: z.url(), uuid: z.string(), }) const SCRAPFLY_SCREENSHOT_INPUT_SCHEMA = z.object({ /** Scroll to the bottom before capturing. */ autoScroll: z.boolean().optional(), /** Read a matching image from Scrapfly's cache. */ cache: z.boolean().optional(), /** Remove a matching cache entry before capture. */ cacheClear: z.boolean().optional(), /** Cache lifetime in seconds. */ cacheTtl: z.number().int().nonnegative().optional(), /** Viewport, fullpage, vertical, CSS selector, or XPath capture target. */ capture: z.string().min(1).optional(), /** ISO 3166-1 alpha-2 proxy country. */ country: z.string().min(1).optional(), /** Image format. Defaults to JPEG. */ format: z.enum(["jpg", "png", "webp", "gif"]).optional(), /** JavaScript evaluated before capture. */ javascript: z.string().optional(), /** Screenshot rendering flags. */ options: z .enum(["load_images", "dark_mode", "block_banners", "print_media_format"]) .array() .min(1) .optional(), /** Viewport formatted as widthxheight. */ resolution: z .string() .regex(/^\d+x\d+$/) .optional(), /** Milliseconds to wait after page rendering. */ renderingWait: z.number().int().nonnegative().optional(), /** Provider timeout in milliseconds. */ timeout: z.number().int().min(60_000).max(120_000).optional(), /** Complete page URL. */ url: z.url(), /** Accessibility vision simulation. */ visionDeficiency: z .enum([ "deuteranopia", "protanopia", "tritanopia", "achromatopsia", "blurredVision", "reducedContrast", ]) .optional(), /** CSS or XPath selector that must appear before capture. */ waitForSelector: z.string().min(1).optional(), /** Dashboard-configured Scrapfly webhook name. */ webhookName: z.string().min(1).optional(), }) const SCRAPFLY_CRAWLER_FORMAT_SCHEMA = z.enum([ "html", "clean_html", "markdown", "text", "json", "extracted_data", "page_metadata", ]) const SCRAPFLY_CRAWLER_EVENT_SCHEMA = z.enum([ "crawler_started", "crawler_url_visited", "crawler_url_skipped", "crawler_url_discovered", "crawler_url_failed", "crawler_stopped", "crawler_cancelled", "crawler_finished", ]) const SCRAPFLY_CRAWLER_STATUS_VALUE_SCHEMA = z.enum([ "PENDING", "RUNNING", "DONE", "CANCELLED", ]) const SCRAPFLY_CRAWLER_INPUT_SCHEMA = z .object({ allowedExternalDomains: z.string().min(1).array().max(250).optional(), allowedInternalSubdomains: z.string().min(1).array().max(250).optional(), asp: z.boolean().optional(), cache: z.boolean().optional(), cacheClear: z.boolean().optional(), cacheTtl: z.number().int().min(0).max(604_800).optional(), contentFormats: SCRAPFLY_CRAWLER_FORMAT_SCHEMA.array().min(1).optional(), country: z.string().min(1).optional(), delay: z.number().int().min(0).max(15_000).optional(), excludePaths: z.string().min(1).array().max(100).optional(), extractionRules: z.record(z.string(), z.json()).optional(), followExternalLinks: z.boolean().optional(), followInternalSubdomains: z.boolean().optional(), headers: z.record(z.string(), z.string()).optional(), ignoreBasePathRestriction: z.boolean().optional(), ignoreNoFollow: z.boolean().optional(), includeOnlyPaths: z.string().min(1).array().max(100).optional(), maxApiCredit: z.number().int().nonnegative().optional(), maxConcurrency: z.number().int().positive().optional(), maxDepth: z.number().int().nonnegative().optional(), maxDuration: z.number().int().min(15).max(10_800).optional(), pageLimit: z.number().int().nonnegative().optional(), proxyPool: z.string().min(1).optional(), remoteUrlList: z.url().optional(), renderingDelay: z.number().int().min(0).max(25_000).optional(), respectRobotsTxt: z.boolean().optional(), url: z.url().optional(), urlList: z.url().array().min(1).optional(), useSitemaps: z.boolean().optional(), userAgent: z.string().min(1).optional(), webhookEvents: SCRAPFLY_CRAWLER_EVENT_SCHEMA.array().min(1).optional(), webhookName: z.string().min(1).optional(), }) .superRefine((input, context) => { if ( [ input.url !== undefined, input.urlList !== undefined, input.remoteUrlList !== undefined, ].filter(Boolean).length !== 1 ) { context.addIssue({ code: "custom", message: "Provide exactly one of url, urlList, or remoteUrlList.", }) } if (input.excludePaths && input.includeOnlyPaths) { context.addIssue({ code: "custom", message: "excludePaths and includeOnlyPaths are mutually exclusive.", }) } }) const SCRAPFLY_CRAWLER_STATE_SCHEMA = z.object({ apiCreditUsed: z.number().nonnegative(), duration: z.number().nonnegative(), startTime: z.number().int().nullable(), stopReason: z .enum([ "no_more_urls", "page_limit", "max_duration", "max_api_credit", "seed_url_failed", "user_cancelled", "crawler_error", "no_api_credit_left", "storage_error", ]) .nullable(), stopTime: z.number().int().nullable(), urlsExtracted: z.number().int().nonnegative(), urlsFailed: z.number().int().nonnegative(), urlsSkipped: z.number().int().nonnegative(), urlsToCrawl: z.number().int().nonnegative(), urlsVisited: z.number().int().nonnegative(), }) const SCRAPFLY_CRAWLER_STATUS_SCHEMA = z.object({ crawlerId: z.string(), isFinished: z.boolean(), isSuccess: z.boolean().nullable(), state: SCRAPFLY_CRAWLER_STATE_SCHEMA, status: SCRAPFLY_CRAWLER_STATUS_VALUE_SCHEMA, }) const SCRAPFLY_MONEY_SCHEMA = z.object({ amount: z.number(), currency: z.string(), }) const SCRAPFLY_ACCOUNT_SCHEMA = z.object({ account: z.object({ accountId: z.string(), currency: z.string(), timezone: z.string(), }), project: z.object({ allowExtraUsage: z.boolean(), allowedNetworks: z.string().array(), budgetLimit: z.json(), budgetSpent: z.json(), concurrencyLimit: z.number().int().nullable().optional(), name: z.string(), quotaReached: z.boolean(), scrapeRequestCount: z.number().int().nonnegative(), scrapeRequestLimit: z.number().int().nonnegative().nullable(), tags: z.string().array(), }), subscription: z.object({ billing: z.object({ currentExtraScrapeRequestPrice: SCRAPFLY_MONEY_SCHEMA, extraScrapeRequestPricePer10k: SCRAPFLY_MONEY_SCHEMA, ongoingPayment: SCRAPFLY_MONEY_SCHEMA, planPrice: SCRAPFLY_MONEY_SCHEMA, }), extraScrapeAllowed: z.boolean(), maxConcurrency: z.number().int().nonnegative(), period: z.object({ end: z.string(), start: z.string() }), planName: z.string(), usage: z.object({ schedule: z.object({ current: z.number(), limit: z.number() }), scrape: z.object({ concurrentLimit: z.number(), concurrentRemaining: z.number(), concurrentUsage: z.number(), current: z.number(), extra: z.number(), limit: z.number(), remaining: z.number(), }), spider: z.object({ current: z.number(), limit: z.number() }), }), }), }) /** Scrapes one URL through Scrapfly. */ export const scrapeUrlWithScrapfly = defineAction("Scrape URL with Scrapfly") .describe( "Scrapes one URL with proxy, rendering, extraction, and screenshot options.", ) .account("scrapfly") .input(SCRAPFLY_SCRAPE_OPTIONS_SCHEMA) .output(SCRAPFLY_SCRAPE_RESULT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getScrapflyClient(account.secret).scrape( toScrapeConfig(input), ) if (result instanceof Response) throw new Error("Unexpected proxified Scrapfly response.") return normalizeScrapeResult(result) }) /** Scrapes up to 100 URLs through Scrapfly's streaming batch endpoint. */ export const scrapeUrlsWithScrapfly = defineAction("Scrape URLs with Scrapfly") .describe( "Scrapes 1–100 independently configured URLs in one atomic Scrapfly batch.", ) .account("scrapfly") .input( z.object({ requests: SCRAPFLY_SCRAPE_OPTIONS_SCHEMA.array().min(1).max(100), }), ) .output( z.object({ results: z .object({ correlationId: z.string(), error: z.object({ message: z.string(), name: z.string() }).optional(), result: SCRAPFLY_SCRAPE_RESULT_SCHEMA.optional(), }) .array(), }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const results = [] for await (const [correlationId, value] of getScrapflyClient( account.secret, ).scrapeBatch( input.requests.map((request, index) => toScrapeConfig({ ...request, correlationId: request.correlationId ?? `request-${index + 1}`, }), ), )) { results.push( value instanceof Error ? { correlationId, error: { message: value.message, name: value.name }, } : value instanceof Response ? { correlationId, error: { message: "Unexpected proxified Scrapfly response.", name: "Error", }, } : { correlationId, result: normalizeScrapeResult(value) }, ) } return { results } }) /** Captures one page screenshot through Scrapfly. */ export const captureScrapflyScreenshot = defineAction( "Capture Scrapfly screenshot", ) .describe( "Captures a viewport, full page, or selected element as an image Blob.", ) .account("scrapfly") .input(SCRAPFLY_SCREENSHOT_INPUT_SCHEMA) .output( z.object({ image: z.instanceof(Blob), metadata: z.object({ extensionName: z.string(), upstreamStatusCode: z.number().int(), upstreamUrl: z.url(), }), }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getScrapflyClient(account.secret).screenshot( new ScreenshotConfig({ auto_scroll: input.autoScroll, cache: input.cache, cache_clear: input.cacheClear, cache_ttl: input.cacheTtl, capture: input.capture, country: input.country, format: input.format, js: input.javascript, options: input.options, rendering_wait: input.renderingWait, resolution: input.resolution, timeout: input.timeout, url: input.url, vision_deficiency: input.visionDeficiency, wait_for_selector: input.waitForSelector, webhook: input.webhookName, }), ) return { image: new Blob([result.image], { type: result.metadata.extension_name === "jpg" ? "image/jpeg" : `image/${result.metadata.extension_name}`, }), metadata: { extensionName: result.metadata.extension_name, upstreamStatusCode: result.metadata.upstream_status_code, upstreamUrl: result.metadata.upstream_url, }, } }) /** Extracts structured or prompted data from supplied document content. */ export const extractContentWithScrapfly = defineAction( "Extract content with Scrapfly", ) .describe( "Extracts data from HTML, Markdown, text, or XML with a prompt, model, or template.", ) .account("scrapfly") .input( z .object({ body: z.string(), charset: z.string().min(1).optional(), contentType: z.enum([ "text/html", "text/markdown", "text/plain", "text/xml", ]), ephemeralTemplate: z.record(z.string(), z.json()).optional(), model: z.string().min(1).optional(), prompt: z.string().min(1).optional(), template: z.string().min(1).optional(), timeout: z.number().int().min(60).max(155).optional(), url: z.url().optional(), webhookName: z.string().min(1).optional(), }) .superRefine((input, context) => { if ( [ input.ephemeralTemplate, input.model, input.prompt, input.template, ].filter((value) => value !== undefined).length !== 1 ) { context.addIssue({ code: "custom", message: "Provide exactly one extraction prompt, model, template, or ephemeralTemplate.", }) } }), ) .output( z.object({ contentType: z.string(), data: z.union([z.string(), z.json()]), }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getScrapflyClient(account.secret).extract( new ExtractionConfig({ body: input.body, charset: input.charset, content_type: input.contentType, extraction_ephemeral_template: input.ephemeralTemplate, extraction_model: input.model, extraction_prompt: input.prompt, extraction_template: input.template, timeout: input.timeout, url: input.url, webhook: input.webhookName, }), ) return { contentType: result.content_type, data: parseExtractedData(result.data, result.content_type), } }) /** Classifies a fetched response for anti-bot blocking. */ export const classifyResponseWithScrapfly = defineAction( "Classify response with Scrapfly", ) .describe( "Runs Scrapfly's anti-bot classifier against an HTTP response you already fetched.", ) .account("scrapfly") .input( z.object({ body: z.string().nullable().optional(), headers: z.record(z.string(), z.string()).optional(), method: z.string().min(1).optional(), statusCode: z.number().int().min(100).max(599), url: z.url(), }), ) .output( z.object({ antibot: z.string().nullable(), blocked: z.boolean(), cost: z.number(), }), ) .retry({ replaySafety: "unsafe" }) .handler( async ({ account, input }) => await getScrapflyClient(account.secret).classify(input), ) /** Gets the connected Scrapfly project's quota and subscription usage. */ export const getScrapflyAccount = defineAction("Get Scrapfly account") .describe( "Returns the project, subscription, quota, concurrency, and usage for the connected key.", ) .account("scrapfly") .output(SCRAPFLY_ACCOUNT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account }) => SCRAPFLY_ACCOUNT_SCHEMA.parse( camelCaseScrapflyValue(await getScrapflyClient(account.secret).account()), ), ) /** Gets aggregate or target Scrapfly monitoring metrics. */ export const getScrapflyMonitoringMetrics = defineAction( "Get Scrapfly monitoring metrics", ) .describe( "Returns Enterprise monitoring metrics for one Scrapfly product and optional target domain.", ) .account("scrapfly") .input( z .object({ aggregation: z .enum(["account", "project", "target"]) .array() .min(1) .optional(), domain: z.string().min(1).optional(), end: z.coerce.date().optional(), groupSubdomain: z.boolean().optional(), includeWebhook: z.boolean().optional(), period: z .enum(["last5m", "last1h", "last7d", "last24h", "subscription"]) .optional(), product: z.enum(["scrape", "screenshot", "extraction", "crawler"]), start: z.coerce.date().optional(), }) .superRefine((input, context) => { if ((input.start === undefined) !== (input.end === undefined)) { context.addIssue({ code: "custom", message: "Provide both start and end.", }) } if ( !input.domain && (input.start || input.end || input.groupSubdomain !== undefined) ) { context.addIssue({ code: "custom", message: "start, end, and groupSubdomain require domain.", }) } if (input.domain && input.aggregation) { context.addIssue({ code: "custom", message: "aggregation is available only without domain.", }) } }), ) .output(z.json()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const client = getScrapflyClient(account.secret) // Preserve one client across the product-specific dispatch and result normalization. const value = input.domain ? await getTargetMonitoringMetrics(client, input) : await getAggregateMonitoringMetrics(client, input) return z.json().parse(camelCaseScrapflyValue(value)) }) /** Starts a Scrapfly crawler job. */ export const startScrapflyCrawl = defineAction("Start Scrapfly crawl") .describe( "Starts a recursive or explicit-list crawl and returns its job identity immediately.", ) .account("scrapfly") .input(SCRAPFLY_CRAWLER_INPUT_SCHEMA) .output( z.object({ crawlerId: z.string(), status: SCRAPFLY_CRAWLER_STATUS_VALUE_SCHEMA, }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getScrapflyClient(account.secret).crawl( toCrawlerConfig(input), ) return { crawlerId: result.crawler_uuid, status: SCRAPFLY_CRAWLER_STATUS_VALUE_SCHEMA.parse(result.status), } }) /** Gets current progress and terminal state for a Scrapfly crawl. */ export const getScrapflyCrawlStatus = defineAction("Get Scrapfly crawl status") .describe("Gets current crawler counters, status, outcome, and stop reason.") .account("scrapfly") .input(z.object({ crawlerId: z.string().min(1) })) .output(SCRAPFLY_CRAWLER_STATUS_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const value = await getScrapflyClient(account.secret).crawlStatus( input.crawlerId, ) return SCRAPFLY_CRAWLER_STATUS_SCHEMA.parse({ crawlerId: value.crawler_uuid, isFinished: value.is_finished, isSuccess: value.is_success, state: camelCaseScrapflyValue(value.state), status: value.status, }) }) /** Lists one page of URLs from a Scrapfly crawl. */ export const listScrapflyCrawlUrls = defineAction("List Scrapfly crawl URLs") .describe( "Lists visited, pending, failed, or skipped crawler URLs with failure reasons.", ) .account("scrapfly") .input( z.object({ crawlerId: z.string().min(1), page: z.number().int().positive().optional(), perPage: z.number().int().min(1).max(1_000).optional(), status: z.enum(["visited", "pending", "failed", "skipped"]).optional(), }), ) .output( z.object({ page: z.number().int().positive(), perPage: z.number().int().positive(), urls: z .object({ reason: z.string().optional(), status: z .enum(["visited", "pending", "failed", "skipped"]) .optional(), url: z.url(), }) .array(), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const result = await getScrapflyClient(account.secret).crawlUrls( input.crawlerId, { page: input.page, per_page: input.perPage, status: input.status, }, ) return { page: result.page, perPage: result.per_page, urls: result.urls } }) /** Gets paginated or single-page content from a Scrapfly crawl. */ export const getScrapflyCrawlContents = defineAction( "Get Scrapfly crawl contents", ) .describe( "Gets one page's raw content or a paginated URL-to-content map from a crawl.", ) .account("scrapfly") .input( z .object({ crawlerId: z.string().min(1), format: SCRAPFLY_CRAWLER_FORMAT_SCHEMA, limit: z.number().int().min(1).max(50).optional(), offset: z.number().int().nonnegative().optional(), plain: z.boolean().optional(), url: z.url().optional(), }) .superRefine((input, context) => { if (input.plain && !input.url) { context.addIssue({ code: "custom", message: "plain requires url." }) } }), ) .output( z.union([ z.object({ content: z.string(), mode: z.literal("plain") }), z.object({ contents: z.record(z.string(), z.record(z.string(), z.string())), links: z.object({ crawledUrls: z.string().optional(), next: z.string().nullable().optional(), prev: z.string().nullable().optional(), }), mode: z.literal("map"), }), ]), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const result = await getScrapflyClient(account.secret).crawlContents( input.crawlerId, { format: input.format, limit: input.limit, offset: input.offset, plain: input.plain, url: input.url, }, ) return typeof result === "string" ? { content: result, mode: "plain" as const } : { contents: result.contents, links: z .object({ crawledUrls: z.string().optional(), next: z.string().nullable().optional(), prev: z.string().nullable().optional(), }) .parse(camelCaseScrapflyValue(result.links)), mode: "map" as const, } }) /** Gets multiple content formats for up to 100 crawled URLs. */ export const getScrapflyCrawlContentsBatch = defineAction( "Get Scrapfly crawl contents batch", ) .describe( "Retrieves selected formats for up to 100 URLs from one completed crawl.", ) .account("scrapfly") .input( z.object({ crawlerId: z.string().min(1), formats: SCRAPFLY_CRAWLER_FORMAT_SCHEMA.array().min(1), urls: z.url().array().min(1).max(100), }), ) .output( z.object({ contents: z.record(z.string(), z.record(z.string(), z.string())), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ({ contents: await getScrapflyClient(account.secret).crawlContentsBatch( input.crawlerId, input.urls, input.formats, ), })) /** Downloads a Scrapfly crawl's WARC or HAR artifact. */ export const downloadScrapflyCrawlArtifact = defineAction( "Download Scrapfly crawl artifact", ) .describe("Downloads a completed crawl as a WARC or HAR Blob.") .account("scrapfly") .input( z.object({ crawlerId: z.string().min(1), type: z.enum(["warc", "har"]).optional(), }), ) .output( z.object({ artifact: z.instanceof(Blob), type: z.enum(["warc", "har"]) }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const result = await getScrapflyClient(account.secret).crawlArtifact( input.crawlerId, input.type, ) return { artifact: new Blob([result.data], { type: result.type === "warc" ? "application/gzip" : "application/json", }), type: result.type, } }) /** Cancels a Scrapfly crawler job idempotently. */ export const cancelScrapflyCrawl = defineAction("Cancel Scrapfly crawl") .describe( "Cancels a pending or running crawl; completed and already-cancelled jobs remain successful no-ops.", ) .account("scrapfly") .input(z.object({ crawlerId: z.string().min(1) })) .output(z.object({ cancelled: z.boolean() })) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ({ cancelled: await getScrapflyClient(account.secret).crawlCancel( input.crawlerId, ), })) /** Parsed public input shared by single and batch scrape mapping. */ type ScrapeInput = z.output /** Parsed public crawler input after cross-field validation. */ type CrawlerInput = z.output type MonitoringProduct = "scrape" | "screenshot" | "extraction" | "crawler" type MonitoringPeriod = | "last5m" | "last1h" | "last7d" | "last24h" | "subscription" interface AggregateMonitoringInput { aggregation?: ("account" | "project" | "target")[] includeWebhook?: boolean period?: MonitoringPeriod product: MonitoringProduct } interface TargetMonitoringInput { domain?: string end?: Date groupSubdomain?: boolean includeWebhook?: boolean period?: MonitoringPeriod product: MonitoringProduct start?: Date } /** * Maps public scrape input into the official SDK config. * * @param input - Camel-case action input. */ function toScrapeConfig(input: ScrapeInput) { return new ScrapeConfig({ auto_scroll: input.autoScroll, asp: input.asp, body: input.body, browser_brand: input.browserBrand, cache: input.cache, cache_clear: input.cacheClear, cache_ttl: input.cacheTtl, cookies: input.cookies, correlation_id: input.correlationId, cost_budget: input.costBudget, country: input.country, dns: input.dns, extraction_model: input.extractionModel, extraction_prompt: input.extractionPrompt, extraction_template: input.extractionTemplate, format: input.format, format_options: input.formatOptions, geolocation: input.geolocation, headers: input.headers, js: input.javascript, js_scenario: input.javascriptScenario, lang: input.languages, method: input.method, os: input.os, proxy_pool: input.proxyPool, raise_on_upstream_error: input.raiseOnUpstreamError, render_js: input.renderJs, rendering_stage: input.renderingStage, rendering_wait: input.renderingWait, screenshots: input.screenshots, screenshot_flags: input.screenshotFlags, session: input.session, session_sticky_proxy: input.session ? input.sessionStickyProxy : false, ssl: input.ssl, tags: input.tags, timeout: input.timeout, url: input.url, wait_for_selector: input.waitForSelector, webhook: input.webhookName, }) } /** * Converts an official SDK scrape result into the public action output. * * @param result - Official SDK result. * @throws When the SDK returns unsupported proxified mode or a malformed * result. */ function normalizeScrapeResult( result: Awaited>, ) { if (result instanceof Response) throw new Error("Unexpected proxified Scrapfly response.") return SCRAPFLY_SCRAPE_RESULT_SCHEMA.parse({ browserData: normalizeBrowserData(result.result.browser_data), content: result.result.content, contentEncoding: result.result.content_encoding, contentType: result.result.content_type, context: camelCaseScrapflyValue(result.context), cookies: camelCaseScrapflyValue(result.result.cookies), ...(result.result.data === undefined ? {} : { data: result.result.data }), ...(result.result.dns === undefined ? {} : { dns: camelCaseScrapflyValue(result.result.dns) }), duration: result.result.duration, ...(result.result.error === undefined ? {} : { error: camelCaseScrapflyValue(result.result.error) }), format: result.result.format, iframes: camelCaseScrapflyValue(result.result.iframes), logUrl: result.result.log_url, reason: result.result.reason, requestHeaders: result.result.request_headers, responseHeaders: result.result.response_headers, screenshots: camelCaseScrapflyValue(result.result.screenshots), size: result.result.size, ...(result.result.ssl === undefined ? {} : { ssl: camelCaseScrapflyValue(result.result.ssl) }), status: result.result.status, statusCode: result.result.status_code, success: result.result.success, url: result.result.url, uuid: result.uuid, }) } /** * Maps browser trace fields while preserving user-defined scenario response * data. * * @param value - Official SDK browser trace. */ function normalizeBrowserData(value: ResultData["browser_data"]) { return { ...(value.javascript_evaluation_result === undefined ? {} : { javascriptEvaluationResult: value.javascript_evaluation_result, }), ...(value.js_scenario ? { jsScenario: { duration: value.js_scenario.duration, executed: value.js_scenario.executed, response: value.js_scenario.response, steps: value.js_scenario.steps, }, } : {}), ...(value.local_storage_data === undefined ? {} : { localStorageData: value.local_storage_data }), ...(value.session_storage_data === undefined ? {} : { sessionStorageData: value.session_storage_data }), ...(value.websockets === undefined ? {} : { websockets: value.websockets }), ...(value.xhr_call === undefined ? {} : { xhrCall: value.xhr_call }), } } /** * Decodes JSON extraction output while preserving malformed provider text. * * @param value - Provider extraction body. * @param contentType - Provider response media type. */ function parseExtractedData(value: string, contentType: string) { if (!contentType.includes("json")) return value try { return z.json().parse(JSON.parse(value)) } catch { return value } } /** * Maps public crawler input into the official SDK config. * * @param input - Camel-case action input. */ function toCrawlerConfig(input: CrawlerInput) { return new CrawlerConfig({ allowed_external_domains: input.allowedExternalDomains, allowed_internal_subdomains: input.allowedInternalSubdomains, asp: input.asp, cache: input.cache, cache_clear: input.cacheClear, cache_ttl: input.cacheTtl, content_formats: input.contentFormats, country: input.country, delay: input.delay, exclude_paths: input.excludePaths, extraction_rules: input.extractionRules, follow_external_links: input.followExternalLinks, follow_internal_subdomains: input.followInternalSubdomains, headers: input.headers, ignore_base_path_restriction: input.ignoreBasePathRestriction, ignore_no_follow: input.ignoreNoFollow, include_only_paths: input.includeOnlyPaths, max_api_credit: input.maxApiCredit, max_concurrency: input.maxConcurrency, max_depth: input.maxDepth, max_duration: input.maxDuration, page_limit: input.pageLimit, proxy_pool: input.proxyPool, remote_url_list: input.remoteUrlList, rendering_delay: input.renderingDelay, respect_robots_txt: input.respectRobotsTxt, url: input.url, url_list: input.urlList, use_sitemaps: input.useSitemaps, user_agent: input.userAgent, webhook_events: input.webhookEvents, webhook_name: input.webhookName, }) } /** * Routes aggregate metrics to the selected provider product. * * @param client - Authenticated provider client. * @param input - Monitoring product and aggregate options. */ async function getAggregateMonitoringMetrics( client: ScrapflyClient, input: AggregateMonitoringInput, ) { const options = { aggregation: input.aggregation, includeWebhook: input.includeWebhook, period: input.period, } if (input.product === "screenshot") return await client.getScreenshotMonitoringMetrics(options) if (input.product === "extraction") return await client.getExtractionMonitoringMetrics(options) if (input.product === "crawler") return await client.getCrawlerMonitoringMetrics(options) return await client.getMonitoringMetrics(options) } /** * Routes target metrics to the selected provider product. * * @param client - Authenticated provider client. * @param input - Monitoring product and target options. */ async function getTargetMonitoringMetrics( client: ScrapflyClient, input: TargetMonitoringInput, ) { const options = { domain: input.domain!, end: input.end, groupSubdomain: input.groupSubdomain, includeWebhook: input.includeWebhook, period: input.period, start: input.start, } if (input.product === "screenshot") return await client.getScreenshotMonitoringTargetMetrics(options) if (input.product === "extraction") return await client.getExtractionMonitoringTargetMetrics(options) if (input.product === "crawler") return await client.getCrawlerMonitoringTargetMetrics(options) return await client.getMonitoringTargetMetrics(options) }