Skip to content

Commit 6d7b952

Browse files
authored
Merge pull request #1135 from asherdrake/fix/single-pass-screenshots
fix: capture crawl/search screenshots in single scraping pass reducing time from 2N pages just down to N (closes #1105)
2 parents 6279ec8 + 1f60733 commit 6d7b952

9 files changed

Lines changed: 211 additions & 123 deletions

File tree

maxun-core/src/interpret.ts

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ interface InterpreterOptions {
4343
binaryCallback: (output: any, mimeType: string) => (void | Promise<void>);
4444
debug: boolean;
4545
type?: 'extract' | 'scrape' | 'crawl' | 'search' | 'doc-extract' | 'doc-parse';
46+
/**
47+
* This outputs the user's pereference for screenshooting a website
48+
* This let's the crawl/search capture the screenshot during the first pass instead
49+
* of having to go and revisit every page afterwards (#1105). This reduces our 2N scan time.
50+
*/
51+
formats?: string[];
4652
debugChannel: Partial<{
4753
activeId: (id: number) => void,
4854
debugMessage: (msg: string) => void,
@@ -385,14 +391,16 @@ export default class Interpreter extends EventEmitter {
385391
* Returns the optimal Playwright `waitUntil` navigation strategy based on
386392
* whether the current operation requires visual rendering.
387393
*
388-
* - `'networkidle'` — used when screenshots are requested; waits for all
389-
* sub-resources so the page renders correctly.
394+
* - `'networkidle'` — used when `visualRenderRequired` (extract mode) is set;
395+
* waits for all sub-resources so the page renders correctly.
396+
* Screenshot capture during crawl/search handles its own
397+
* networkidle wait separately, in captureScreenshotsForCurrentPage.
390398
* - `'domcontentloaded'` — used for all DOM-only operations (scraping, crawling,
391399
* extraction, search); skips stylesheet/image loading for
392400
* maximum speed.
393401
*
394-
* @param blockOverride Pass `true` when the caller will take a screenshot
395-
* or requires styled layout. Defaults to `false`.
402+
* @param blockOverride Pass `true` to force a fully-rendered wait regardless of
403+
* `visualRenderRequired`. Defaults to `false`.
396404
*/
397405
private getNavigationWaitStrategy(
398406
blockOverride?: boolean
@@ -426,6 +434,64 @@ export default class Interpreter extends EventEmitter {
426434
});
427435
}
428436

437+
/**
438+
* If user selected either screenshot-visible or screenshot-fullpage output format,
439+
* this returns true.
440+
*/
441+
private screenshotFormatsRequested(): boolean {
442+
const formats = this.options.formats ?? [];
443+
return formats.includes('screenshot-visible') || formats.includes('screenshot-fullpage');
444+
}
445+
446+
private async captureScreenshotsForCurrentPage(
447+
page: Page,
448+
keyPrefix: 'crawl' | 'search',
449+
pageNumber: number,
450+
): Promise<{ screenshotVisible?: string; screenshotFullpage?: string }> {
451+
const keys: { screenshotVisible?: string; screenshotFullpage?: string } = {};
452+
if (!this.screenshotFormatsRequested()) return keys;
453+
454+
const formats = this.options.formats ?? [];
455+
456+
// Bounded, non-fatal render-readiness before screenshotting.
457+
// A capped networkidle race lets never-idle pages (chat/ads/websockets) settle
458+
// without hanging navigation; timeouts here are swallowed, never fatal. Navigation
459+
// stays on the fast domcontentloaded path (see the crawl/search goto calls).
460+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
461+
await this.waitForImagesLoaded(page);
462+
await new Promise(resolve => setTimeout(resolve, 1000));
463+
464+
if (formats.includes('screenshot-visible')) {
465+
try {
466+
const buffer = await page.screenshot({ type: 'png' });
467+
const key = `${keyPrefix}-${pageNumber}-screenshot-visible`;
468+
await this.options.binaryCallback(
469+
{ name: key, data: buffer, mimeType: 'image/png' },
470+
'image/png',
471+
);
472+
keys.screenshotVisible = key;
473+
} catch (error: any) {
474+
this.log(`Failed to capture visible screenshot of ${page.url()}: ${error.message}`, Level.WARN);
475+
}
476+
}
477+
478+
if (formats.includes('screenshot-fullpage')) {
479+
try {
480+
const buffer = await page.screenshot({ type: 'png', fullPage: true });
481+
const key = `${keyPrefix}-${pageNumber}-screenshot-fullpage`;
482+
await this.options.binaryCallback(
483+
{ name: key, data: buffer, mimeType: 'image/png' },
484+
'image/png',
485+
);
486+
keys.screenshotFullpage = key;
487+
} catch (error: any) {
488+
this.log(`Failed to capture full-page screenshot of ${page.url()}: ${error.message}`, Level.WARN);
489+
}
490+
}
491+
492+
return keys;
493+
}
494+
429495
/**
430496
* Returns true if any of the remaining blocks in the workflow require a visual render
431497
* before the next page navigation.
@@ -1484,6 +1550,12 @@ export default class Interpreter extends EventEmitter {
14841550
pageResult.metadata.depth = depth;
14851551
crawlResults.push(pageResult);
14861552

1553+
// Screenshots are taken here instead of 2N after every crawl (#1105).
1554+
Object.assign(
1555+
pageResult,
1556+
await this.captureScreenshotsForCurrentPage(page, 'crawl', crawlResults.length),
1557+
);
1558+
14871559
this.log(`✓ Scraped ${url} (${pageResult.wordCount} words, depth ${depth})`, Level.LOG);
14881560

14891561
if (crawlConfig.followLinks && depth < crawlConfig.maxDepth) {
@@ -1830,8 +1902,8 @@ export default class Interpreter extends EventEmitter {
18301902
await page.goto(result.url, {
18311903
waitUntil: this.getNavigationWaitStrategy(),
18321904
timeout: 30000
1833-
}).catch(() => {
1834-
this.log(`Failed to navigate to ${result.url}, skipping...`, Level.WARN);
1905+
}).catch((err) => {
1906+
throw new Error(`Navigation failed: ${err.message}`);
18351907
});
18361908

18371909
await this.waitForPageReady(page, currentWorkflow || []);
@@ -1883,7 +1955,7 @@ export default class Interpreter extends EventEmitter {
18831955
};
18841956
});
18851957

1886-
scrapedResults.push({
1958+
const scrapedEntry = {
18871959
searchResult: {
18881960
query: searchConfig.query,
18891961
position: result.position,
@@ -1900,7 +1972,14 @@ export default class Interpreter extends EventEmitter {
19001972
links: pageData.links,
19011973
wordCount: pageData.wordCount,
19021974
scrapedAt: new Date().toISOString()
1903-
});
1975+
};
1976+
scrapedResults.push(scrapedEntry);
1977+
1978+
// Screenshots are taken here instead of 2N after every crawl (#1105).
1979+
Object.assign(
1980+
scrapedEntry,
1981+
await this.captureScreenshotsForCurrentPage(page, 'search', i + 1),
1982+
);
19041983

19051984
this.log(`✓ Scraped ${result.url} (${pageData.wordCount} words)`, Level.LOG);
19061985

server/src/api/record.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1150,7 +1150,6 @@ async function executeRun(id: string, userId: string) {
11501150
crawl: categorizedOutput.crawl as Record<string, any>,
11511151
search: categorizedOutput.search as Record<string, any>,
11521152
},
1153-
currentPage,
11541153
initialBinaryOutput: postBinaryOutput,
11551154
llmConfig,
11561155
});

server/src/routes/storage.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,19 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) =>
10511051
return res.status(404).send({ error: 'Recording not found' });
10521052
}
10531053

1054+
// Validate formats once before persisting; a malformed value (e.g. an object)
1055+
// would otherwise reach the interpreter and break formats.includes(...).
1056+
let interpreterFormats = (recording.recording_meta as any).formats;
1057+
if (req.body?.formats !== undefined) {
1058+
const { validFormats, invalidFormats } = parseOutputFormats(req.body.formats);
1059+
if (invalidFormats.length > 0) {
1060+
return res.status(400).json({
1061+
error: `Invalid formats: ${invalidFormats.map(String).join(', ')}`,
1062+
});
1063+
}
1064+
interpreterFormats = validFormats;
1065+
}
1066+
10541067
// Generate runId first
10551068
const runId = uuid();
10561069

@@ -1081,8 +1094,8 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) =>
10811094
robotMetaId: recording.recording_meta.id,
10821095
startedAt: new Date().toLocaleString(),
10831096
finishedAt: '',
1084-
browserId: browserId,
1085-
interpreterSettings: req.body,
1097+
browserId: browserId,
1098+
interpreterSettings: { ...req.body, formats: interpreterFormats },
10861099
log: '',
10871100
runId,
10881101
runByUserId: req.user.id,
@@ -1147,7 +1160,7 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) =>
11471160
startedAt: new Date().toLocaleString(),
11481161
finishedAt: '',
11491162
browserId,
1150-
interpreterSettings: req.body,
1163+
interpreterSettings: { ...req.body, formats: interpreterFormats },
11511164
log: 'Run queued - waiting for available browser slot',
11521165
runId,
11531166
runByUserId: req.user.id,

server/src/storage/mino.ts

Lines changed: 83 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ async function fixMinioBucketConfiguration(bucketName: string) {
3838
}
3939
}
4040

41+
/**
42+
* Bucket existence/policy is external, process-global state, so the check only
43+
* needs to succeed once per bucket per process. The in-flight promise is cached
44+
* so concurrent first uploads share a single check; a failed check is evicted
45+
* so the next upload retries instead of caching the failure.
46+
*/
47+
const bucketConfigPromises = new Map<string, Promise<void>>();
48+
49+
function ensureBucketConfigured(bucketName: string): Promise<void> {
50+
let promise = bucketConfigPromises.get(bucketName);
51+
if (!promise) {
52+
promise = fixMinioBucketConfiguration(bucketName).catch((error) => {
53+
bucketConfigPromises.delete(bucketName);
54+
throw error;
55+
});
56+
bucketConfigPromises.set(bucketName, promise);
57+
}
58+
return promise;
59+
}
60+
4161
minioClient.bucketExists('maxun-test')
4262
.then((exists) => {
4363
if (exists) {
@@ -90,8 +110,54 @@ class BinaryOutputService {
90110
this.bucketName = bucketName;
91111
}
92112

113+
/**
114+
* Uploads a single binary item to MinIO and returns its public URL.
115+
* Never throws: a null return lets callers fall back to keeping the
116+
* base64 payload, which the end-of-run bulk upload will retry.
117+
* @param runId - The run ID used to namespace the object key.
118+
* @param key - The binary output key (also the object name within the run).
119+
* @param data - The raw binary data to upload.
120+
* @param mimeType - The MIME type of the data (defaults to image/png).
121+
* @returns The public URL of the uploaded object, or null on failure.
122+
*/
123+
async uploadBinaryOutputItem(runId: string, key: string, data: Buffer, mimeType?: string): Promise<string | null> {
124+
try {
125+
await ensureBucketConfigured(this.bucketName);
126+
127+
const minioKey = `${runId}/${encodeURIComponent(key.trim().replace(/\s+/g, '_'))}`;
128+
129+
console.log(`Uploading to bucket ${this.bucketName} with key ${minioKey}`);
130+
131+
await minioClient.putObject(
132+
this.bucketName,
133+
minioKey,
134+
data,
135+
data.length,
136+
{ 'Content-Type': mimeType || 'image/png' }
137+
);
138+
139+
// Build the browser-facing object URL. Prefer a complete public base URL
140+
// (MINIO_PUBLIC_URL) so deployments behind a reverse proxy or on a
141+
// non-default public port are not forced onto the internal MinIO port —
142+
// which produced unreachable "localhost:9000" links (#832). Falls back to
143+
// the existing host:port composition for local/dev setups.
144+
const publicBase = (
145+
process.env.MINIO_PUBLIC_URL
146+
? process.env.MINIO_PUBLIC_URL
147+
: `${process.env.MINIO_PUBLIC_HOST || 'http://localhost'}:${process.env.MINIO_PORT || '9000'}`
148+
).replace(/\/+$/, '');
149+
150+
return `${publicBase}/${this.bucketName}/${minioKey}`;
151+
} catch (error) {
152+
console.error(`❌ Error uploading key ${key} to MinIO:`, error);
153+
return null;
154+
}
155+
}
156+
93157
/**
94158
* Uploads binary data to Minio and stores references in PostgreSQL.
159+
* Entries that are already public URLs (uploaded per-page during the run)
160+
* are preserved as-is, making this call idempotent for them.
95161
* @param run - The run object representing the current process.
96162
* @param binaryOutput - The binary output object containing data to upload.
97163
* @returns A map of Minio URLs pointing to the uploaded binary data.
@@ -110,6 +176,18 @@ class BinaryOutputService {
110176

111177
console.log(`Processing binary output key: ${key}`);
112178

179+
// Entries uploaded per-page during the run are already public URLs —
180+
// pass them through untouched, so the run.update below (a full replace
181+
// of run.binaryOutput) cannot wipe them.
182+
if (typeof binaryData === 'string' && /^https?:\/\//.test(binaryData)) {
183+
uploadedBinaryOutput[key] = binaryData;
184+
continue;
185+
}
186+
if (binaryData && typeof binaryData.data === 'string' && /^https?:\/\//.test(binaryData.data)) {
187+
uploadedBinaryOutput[key] = binaryData.data;
188+
continue;
189+
}
190+
113191
// Convert binary data to Buffer (handles base64, data URI, and old Buffer format)
114192
let bufferData: Buffer | null = null;
115193

@@ -151,38 +229,13 @@ class BinaryOutputService {
151229
continue;
152230
}
153231

154-
try {
155-
await fixMinioBucketConfiguration(this.bucketName);
156-
157-
const minioKey = `${plainRun.runId}/${encodeURIComponent(key.trim().replace(/\s+/g, '_'))}`;
158-
159-
console.log(`Uploading to bucket ${this.bucketName} with key ${minioKey}`);
160-
161-
await minioClient.putObject(
162-
this.bucketName,
163-
minioKey,
164-
bufferData,
165-
bufferData.length,
166-
{ 'Content-Type': binaryData.mimeType || 'image/png' }
167-
);
168-
169-
// Build the browser-facing object URL. Prefer a complete public base URL
170-
// (MINIO_PUBLIC_URL) so deployments behind a reverse proxy or on a
171-
// non-default public port are not forced onto the internal MinIO port —
172-
// which produced unreachable "localhost:9000" links (#832). Falls back to
173-
// the existing host:port composition for local/dev setups.
174-
const publicBase = (
175-
process.env.MINIO_PUBLIC_URL
176-
? process.env.MINIO_PUBLIC_URL
177-
: `${process.env.MINIO_PUBLIC_HOST || 'http://localhost'}:${process.env.MINIO_PORT || '9000'}`
178-
).replace(/\/+$/, '');
179-
const publicUrl = `${publicBase}/${this.bucketName}/${minioKey}`;
180-
232+
const publicUrl = await this.uploadBinaryOutputItem(plainRun.runId, key, bufferData, binaryData.mimeType);
233+
if (publicUrl) {
181234
uploadedBinaryOutput[key] = publicUrl;
182-
183235
console.log(`✅ Uploaded and stored: ${publicUrl}`);
184-
} catch (error) {
185-
console.error(`❌ Error uploading key ${key} to MinIO:`, error);
236+
} else {
237+
// Keep the base64 entry rather than dropping it, so run.update doesn't remove it
238+
uploadedBinaryOutput[key] = binaryData;
186239
}
187240
}
188241

server/src/task-runner.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,6 @@ async function processRunExecution(data: ExecuteRunData): Promise<void> {
437437
robotType,
438438
outputFormats,
439439
categorizedOutput: { crawl: categorizedOutput.crawl as Record<string, any>, search: categorizedOutput.search as Record<string, any> },
440-
currentPage,
441440
initialBinaryOutput: binaryOutput,
442441
llmConfig: {
443442
provider: ((recording.recording_meta as any).promptLlmProvider || 'ollama') as 'anthropic' | 'openai' | 'ollama',

server/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface InterpreterSettings {
88
maxConcurrency: number;
99
maxRepeats: number;
1010
debug: boolean;
11+
formats?: string[];
1112
params?: any;
1213
}
1314

0 commit comments

Comments
 (0)