Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Optional MHTML archive: `mhtml: true` on `POST /scrape` returns a `multipart/related` archive of the page in `ScrapeResult.mhtml` — the rendered document first, then the stylesheets, scripts, images and fonts observed loading, each with its own `Content-Location`. Assembled from the responses the browser tiers already see rather than snapshotted by the engine (Firefox has no `Page.captureSnapshot`), so it is an approximation of "Save as MHTML": a resource served from cache or fetched before the listener attached is absent. Off by default — without the flag no subresource body is read. Part count, per-part bytes, reads in flight and total archive size are bounded and tunable via `MHTML_*`; an oversize part is omitted whole rather than trimmed, and every omission is counted in an `X-Trawl-Omitted-Resources` header and listed in a final part so a capped archive is still a valid MHTML.

## [1.5.0] - 2026-09-04

### Changed
Expand Down
19 changes: 19 additions & 0 deletions apps/docs/api-reference/native-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface ScrapeRequest {
captureResponses?: string[] // URL patterns whose response bodies to capture, default none
settleTimeout?: number // ms to wait after load for a match, default 15000
waitForSelector?: string // CSS selector that ends the settle window early
mhtml?: boolean // assemble an MHTML archive of the page, default false
}
```

Expand All @@ -46,6 +47,7 @@ interface ScrapeRequest {
| `captureResponses` | string[] | — | URL patterns — a substring, or a glob matched against the whole URL when the pattern contains `*` or `?` — whose response bodies are returned as `capturedResponses` (browser tiers 2–4) |
| `settleTimeout` | number | 15000 | Milliseconds to hold the page open after load waiting for a match; ends early on the first captured body, on `waitForSelector`, or on network idle. Only read alongside `captureResponses` |
| `waitForSelector` | string | — | CSS selector that also ends the settle window early. Only read alongside `captureResponses` |
| `mhtml` | boolean | false | Assemble a `multipart/related` MHTML archive of the page on the browser tiers (2–4) and return it as `mhtml`. An approximation of "Save as MHTML", not an engine snapshot — see the note below |

Captured response bodies, headers, console messages, and URLs can contain credentials,
tokens, or personal data. Treat these opt-in diagnostic fields as sensitive output.
Expand All @@ -70,6 +72,7 @@ interface ScrapeResult {
networkLogs?: NetworkLogEntry[] // resource timings, same presence rules as consoleLogs
redirectChain?: string[] // URLs the main document walked, same presence rules as consoleLogs
capturedResponses?: CapturedResponseEntry[] // matched response bodies, [] when nothing matched
mhtml?: string // multipart/related archive of the page, only when requested and a browser tier served the page
}

interface ConsoleLogEntry {
Expand Down Expand Up @@ -108,6 +111,22 @@ interface TierResult {
}
```

## MHTML Archives

`mhtml: true` returns a single `multipart/related` document: the rendered DOM first, then
the stylesheets, scripts, images and fonts that were observed loading. It is pure 7-bit
ASCII with CRLF line endings, so it can be written straight to a `.mhtml` file, and every
part carries a `Content-Location` so a reader can resolve it back to its URL.

It is an **assembled approximation, not an engine snapshot.** Firefox exposes no
equivalent of Chromium's `Page.captureSnapshot`, so the archive is built from what the
response listener saw: a resource the browser served from its own cache, fetched before
the listener attached, or refused to hand over is absent. Anything dropped for a byte
budget is counted in the `X-Trawl-Omitted-Resources` header and listed in a final
`text/plain` part, so an archive that hits a cap is still a valid MHTML that says what it
is missing. Bounds are tunable via `MHTML_*` — see
[Configuration](/getting-started/configuration#mhtml-archives).

## Examples

### Minimal request
Expand Down
25 changes: 25 additions & 0 deletions apps/docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,31 @@ bodies with a valid `Content-Length` are read. Compressed or unknown-size bodies
returned with `body: null` and an error. Declared sizes are reserved cumulatively before
reads start, so concurrent responses cannot exceed the total read budget.

## MHTML Archives

Only read when a request sets `mhtml: true` — see
[Native API](/api-reference/native-api#mhtml-archives). Without it no subresource body is
read. The archive keeps many small parts rather than a few large bodies, so it carries
budgets of its own rather than sharing the response-body ones.

| Variable | Default | Purpose |
| --- | ---: | --- |
| `MHTML_MAX_PARTS` | `200` | Subresources archived per page |
| `MHTML_MAX_PART_BYTES` | `2097152` | Bytes per subresource; a larger one is omitted whole |
| `MHTML_MAX_TOTAL_CHARS` | `8388608` | Encoded characters across all subresources of one page — the size of the archive itself |
| `MHTML_MAX_INFLIGHT_READS` | `32` | Subresource bodies read at the same time; a burst past this is omitted rather than held |
| `MHTML_MAX_OMISSION_RECORDS` | `100` | Omissions listed by URL in the archive; the rest are only counted |

Bodies are read as they arrive, so a page whose subresources all complete at once is
bounded twice over: by `MHTML_MAX_INFLIGHT_READS`, and by the archive budget itself
wherever the response declared a `Content-Length`. At the defaults an archiving request
holds at most the archive (8 MiB) plus its reads in flight.

A subresource is omitted rather than trimmed — a truncated stylesheet or image is corrupt,
not partial. Every omission is counted in the archive's `X-Trawl-Omitted-Resources` header
and listed in its final part, so an archive that hits a cap is still valid and still says
what is missing. An assembly failure leaves `mhtml` unset and never fails the scrape.

## CAPTCHA audio and media tools

TRAWL uses ffmpeg while solving supported CAPTCHA challenges. reCAPTCHA audio is converted before
Expand Down
4 changes: 4 additions & 0 deletions packages/tiers/src/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export async function scrape(
captureResponses: req.captureResponses,
settleTimeout: req.settleTimeout,
waitForSelector: req.waitForSelector,
mhtml: req.mhtml,
}

const sanitizedHeaders = sanitizeHeaders(req.headers)
Expand Down Expand Up @@ -225,6 +226,7 @@ export async function scrape(
networkLogs: t2.networkLogs,
redirectChain: t2.redirectChain,
capturedResponses: t2.capturedResponses,
mhtml: t2.mhtml,
}
}
// Session failed — purge it
Expand Down Expand Up @@ -313,6 +315,7 @@ export async function scrape(
networkLogs: t3.networkLogs,
redirectChain: t3.redirectChain,
capturedResponses: t3.capturedResponses,
mhtml: t3.mhtml,
}
}

Expand Down Expand Up @@ -400,6 +403,7 @@ export async function scrape(
networkLogs: t4.networkLogs,
redirectChain: t4.redirectChain,
capturedResponses: t4.capturedResponses,
mhtml: t4.mhtml,
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/tiers/src/tiers/2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface Tier2Result extends TierResult {
networkLogs?: NetworkLogEntry[]
redirectChain?: string[]
capturedResponses?: CapturedResponseEntry[]
mhtml?: string
}

export async function runTier2(
Expand Down Expand Up @@ -172,6 +173,7 @@ export async function runTier2(
screenshot: shot,
...evidence,
redirectChain: capture.redirectChain ? mainResponse.redirectChain : undefined,
mhtml: pageCapture.archive(page.url(), finalHtml),
}
} catch (err) {
return {
Expand Down
2 changes: 2 additions & 0 deletions packages/tiers/src/tiers/3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface Tier3Result extends TierResult {
networkLogs?: NetworkLogEntry[]
redirectChain?: string[]
capturedResponses?: CapturedResponseEntry[]
mhtml?: string
}

export async function runTier3(
Expand Down Expand Up @@ -258,6 +259,7 @@ export async function runTier3(
screenshot: shot,
...evidence,
redirectChain: capture.redirectChain ? mainResponse.redirectChain : undefined,
mhtml: pageCapture.archive(page.url(), html),
}
} catch (err) {
return {
Expand Down
2 changes: 2 additions & 0 deletions packages/tiers/src/tiers/4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface Tier4Result extends TierResult {
networkLogs?: NetworkLogEntry[]
redirectChain?: string[]
capturedResponses?: CapturedResponseEntry[]
mhtml?: string
}

export async function runTier4(
Expand Down Expand Up @@ -230,6 +231,7 @@ export async function runTier4(
screenshot: shot,
...evidence,
redirectChain: capture.redirectChain ? mainResponse.redirectChain : undefined,
mhtml: pageCapture.archive(page.url(), html),
}
} catch (err) {
return {
Expand Down
9 changes: 7 additions & 2 deletions packages/tiers/src/utils/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ export interface PageCapture {
/** Holds the page open for the response-capture settle window; a no-op otherwise. */
settle(budgetMs: number): Promise<void>
drain(budgetMs?: number): Promise<CapturedPageEvidence>
/** Multipart/related archive of the observed subresources; undefined unless asked for. */
archive(url: string, html: string): string | undefined
}

const NO_CAPTURE: PageCapture = { settle: async () => {}, drain: async () => ({}) }
const NO_CAPTURE: PageCapture = { settle: async () => {}, drain: async () => ({}), archive: () => undefined }

const ms = (value: number): number => Math.round(value * 100) / 100

Expand All @@ -53,13 +55,15 @@ const ms = (value: number): number => Math.round(value * 100) / 100
* the caller — a capture failure degrades that field, not the scrape.
*/
export function attachPageCapture(page: Page, options: CaptureOptions): PageCapture {
if (!options.consoleLogs && !options.networkLogs && !options.captureResponses?.length) return NO_CAPTURE
if (!options.consoleLogs && !options.networkLogs && !options.captureResponses?.length && !options.mhtml)
return NO_CAPTURE

const responses = attachResponseCapture(page, options)
if (!options.consoleLogs && !options.networkLogs) {
return {
settle: (budgetMs) => responses.settle(budgetMs),
drain: async (budgetMs) => ({ capturedResponses: await responses.drain(budgetMs) }),
archive: (url, html) => responses.archive(url, html),
}
}

Expand Down Expand Up @@ -154,6 +158,7 @@ export function attachPageCapture(page: Page, options: CaptureOptions): PageCapt

return {
settle: (budgetMs) => responses.settle(budgetMs),
archive: (url, html) => responses.archive(url, html),
async drain(budgetMs = SIZES_TIMEOUT_MS) {
const drainStarted = Date.now()
const totalBudget = Math.max(0, Number.isFinite(budgetMs) ? budgetMs : 0)
Expand Down
187 changes: 187 additions & 0 deletions packages/tiers/src/utils/mhtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { randomBytes } from "node:crypto"

// Firefox exposes no equivalent of Chromium's Page.captureSnapshot, so the archive is
// assembled from the subresources the response listener already observed rather than
// serialized by the engine. It is a valid multipart/related document, not a byte-faithful
// snapshot: anything the browser served from cache, fetched before the listener attached,
// or refused to hand over is absent, and the omission part records what is missing.
const ARCHIVED_RESOURCE_TYPES = new Set(["document", "stylesheet", "script", "image", "font"])

const DEFAULT_CONTENT_TYPES: Record<string, string> = {
document: "text/html",
stylesheet: "text/css",
script: "application/javascript",
image: "application/octet-stream",
font: "application/octet-stream",
}

// Longest line a quoted-printable or base64 part may use, per RFC 2045.
const MAX_LINE_CHARS = 76

// A header line is not folded, so a Content-Location longer than this is not archivable —
// folding a URL is what makes an MHTML unreadable in the browsers that reject it.
export const MAX_LOCATION_CHARS = 2_000

export type MhtmlOmissionReason =
| "over-part-budget"
| "archive-budget-exhausted"
| "part-count-cap"
| "read-slots-busy"
| "body-read-failed"
| "location-too-long"

export interface MhtmlPart {
location: string
contentType: string
encoding: "quoted-printable" | "base64"
content: string
}

export interface MhtmlOmission {
location: string
reason: MhtmlOmissionReason
}

export const isArchivableResourceType = (resourceType: string): boolean => ARCHIVED_RESOURCE_TYPES.has(resourceType)

export const defaultContentType = (resourceType: string): string =>
DEFAULT_CONTENT_TYPES[resourceType] ?? "application/octet-stream"

/** Strips anything a header line cannot carry — a line break above all. */
const headerSafe = (value: string, maxChars = MAX_LOCATION_CHARS): string => {
let safe = ""
for (const char of value) {
const code = char.codePointAt(0) ?? 0
safe += code >= 0x20 && code <= 0x7e ? char : encodeURIComponent(char)
if (safe.length >= maxChars) break
}
return safe.slice(0, maxChars)
}

const wrap = (value: string): string => {
const lines: string[] = []
for (let at = 0; at < value.length; at += MAX_LINE_CHARS) lines.push(value.slice(at, at + MAX_LINE_CHARS))
return lines.join("\r\n")
}

const HEX = "0123456789ABCDEF"

const escaped = (byte: number): string => `=${HEX[byte >> 4]}${HEX[byte & 0xf]}`

/**
* RFC 2045 quoted-printable, over the raw bytes rather than a decoded string, so a part
* whose charset is not UTF-8 stays byte-faithful under its own declared charset.
*/
export const toQuotedPrintable = (raw: Buffer): string => {
const lines: string[] = []
let line = ""

const push = (token: string) => {
if (line.length + token.length > MAX_LINE_CHARS - 1) {
lines.push(`${line}=`)
line = ""
}
line += token
}

const breakLine = () => {
// Trailing whitespace would be eaten by a transport that rewraps lines.
const last = line.at(-1)
if (last === " " || last === "\t") {
line = line.slice(0, -1)
push(escaped(last === " " ? 0x20 : 0x09))
}
lines.push(line)
line = ""
}

for (let at = 0; at < raw.length; at++) {
const byte = raw[at]
if (byte === 0x0d && raw[at + 1] === 0x0a) {
at++
breakLine()
} else if (byte === 0x0a || byte === 0x0d) {
breakLine()
} else if (byte === 0x3d || byte < 0x20 || byte > 0x7e) {
push(escaped(byte))
} else {
push(String.fromCharCode(byte))
}
}

breakLine()
return lines.join("\r\n")
}

export const encodePart = (raw: Buffer, asText: boolean): Pick<MhtmlPart, "encoding" | "content"> =>
asText
? { encoding: "quoted-printable", content: toQuotedPrintable(raw) }
: { encoding: "base64", content: wrap(raw.toString("base64")) }

const omissionPart = (omissions: MhtmlOmission[], omitted: number): string => {
const listed = omissions.map((o) => `${o.reason} ${headerSafe(o.location)}`).join("\r\n")
const unlisted = omitted > omissions.length ? `\r\n(${omitted - omissions.length} further omissions not listed)` : ""
return `${omitted} resource(s) omitted from this archive.\r\n${listed}${unlisted}`
}

export interface MhtmlDocument {
url: string
html: string
parts: MhtmlPart[]
omissions: MhtmlOmission[]
omitted: number
}

/**
* Assembles one multipart/related archive with the main document first. The boundary is
* checked against every part so no content can terminate the archive early.
*/
export function assembleMhtml(document: MhtmlDocument): string {
const main: MhtmlPart = {
location: document.url,
contentType: "text/html; charset=utf-8",
...encodePart(Buffer.from(document.html, "utf8"), true),
}
const parts = [main, ...document.parts.filter((part) => part.location !== document.url)]
if (document.omitted > 0) {
parts.push({
location: "",
contentType: "text/plain; charset=utf-8",
...encodePart(Buffer.from(omissionPart(document.omissions, document.omitted), "utf8"), true),
})
}

let boundary = ""
for (let attempt = 0; attempt < 4; attempt++) {
boundary = `----MultipartBoundary--trawl${randomBytes(12).toString("hex")}----`
if (!parts.some((part) => part.content.includes(boundary))) break
}

const header = [
"From: <Saved by TRAWL>",
`Snapshot-Content-Location: ${headerSafe(document.url)}`,
`Date: ${new Date().toUTCString()}`,
"MIME-Version: 1.0",
"X-Trawl-Archive: assembled-from-observed-subresources",
...(document.omitted > 0 ? [`X-Trawl-Omitted-Resources: ${document.omitted}`] : []),
`Content-Type: multipart/related; type="text/html"; boundary="${boundary}"`,
].join("\r\n")

const body = parts
.map((part) =>
[
`--${boundary}`,
`Content-Type: ${headerSafe(part.contentType, 200)}`,
`Content-Transfer-Encoding: ${part.encoding}`,
part.location
? `Content-Location: ${headerSafe(part.location)}`
: "Content-ID: <trawl-omitted-resources@trawl.invalid>",
"",
part.content,
"",
].join("\r\n"),
)
.join("")

return `${header}\r\n\r\n${body}--${boundary}--\r\n`
}
Loading