feat(chromium): screenshot a single element via the selector form field (#947)

This commit is contained in:
Julien Neuhart
2026-08-13 13:56:17 +02:00
parent 2f6818d3df
commit 8d1eeaa73a
9 changed files with 184 additions and 2 deletions

View File

@@ -43,6 +43,10 @@ var (
// or undefined.
ErrInvalidSelectorQuery = errors.New("invalid selector query")
// ErrScreenshotSelectorNotFound happens when the CSS selector of a
// screenshot matches no element with a rendered box.
ErrScreenshotSelectorNotFound = errors.New("screenshot selector not found")
// ErrRpccMessageTooLarge happens when the messages received by
// ChromeDevTools are larger than 100 MB.
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
@@ -347,6 +351,11 @@ type ScreenshotOptions struct {
// dimensions.
Clip bool
// Selector clips the screenshot to the bounding box of the first element
// matching this CSS selector. Empty captures the whole page. Takes
// precedence over Clip.
Selector string
// Format is the image compression format, either "png" or "jpeg" or
// "webp".
Format string
@@ -370,6 +379,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
Width: 800,
Height: 600,
Clip: false,
Selector: "",
Format: "png",
Quality: 100,
OptimizeForSpeed: false,

View File

@@ -366,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
var (
width, height int
clip bool
selector string
format string
quality int
optimizeForSpeed bool
@@ -376,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
Int("width", &width, defaultScreenshotOptions.Width).
Int("height", &height, defaultScreenshotOptions.Height).
Bool("clip", &clip, defaultScreenshotOptions.Clip).
String("selector", &selector, defaultScreenshotOptions.Selector).
Custom("format", func(value string) error {
if value == "" {
format = defaultScreenshotOptions.Format
@@ -420,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
Width: width,
Height: height,
Clip: clip,
Selector: selector,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
@@ -963,7 +966,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
outputPath := ctx.GeneratePath(ext)
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
err = handleChromiumError(err, options.Options)
if errors.Is(err, ErrScreenshotSelectorNotFound) {
err = api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The selector '%s' (selector) matched no element with a visible box", options.Selector),
),
)
} else {
err = handleChromiumError(err, options.Options)
}
if err != nil {
return fmt.Errorf("screenshot: %w", err)
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"os"
"strconv"
"time"
"github.com/chromedp/cdproto/cdp"
@@ -188,7 +189,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
WithOptimizeForSpeed(options.OptimizeForSpeed).
WithFormat(page.CaptureScreenshotFormat(options.Format))
if options.Clip {
switch {
case options.Selector != "":
clip, err := elementClip(ctx, options.Selector)
if err != nil {
return err
}
logger.DebugContext(ctx, fmt.Sprintf("clip screenshot to selector '%s'", options.Selector))
captureScreenshot = captureScreenshot.WithClip(clip)
case options.Clip:
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
Width: float64(options.Width),
Height: float64(options.Height),
@@ -229,6 +239,49 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
}
}
// elementClip resolves the first element matching selector to a page-space clip
// rectangle for Page.captureScreenshot.
//
// getBoundingClientRect reports viewport-relative CSS pixels; adding the scroll
// offset puts the rectangle in the document coordinate space that
// WithCaptureBeyondViewport expects. It fails with
// [ErrScreenshotSelectorNotFound] when nothing matches or the match has no
// rendered box (display:none or a zero area), so the caller can answer 400.
func elementClip(ctx context.Context, selector string) (*page.Viewport, error) {
var rect struct {
Found bool `json:"found"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
expr := fmt.Sprintf(`(() => {
const el = document.querySelector(%s);
if (!el) {
return { found: false };
}
const r = el.getBoundingClientRect();
return { found: true, x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
})()`, strconv.Quote(selector))
err := chromedp.Evaluate(expr, &rect).Do(ctx)
if err != nil {
return nil, fmt.Errorf("evaluate selector box: %v: %w", err, ErrScreenshotSelectorNotFound)
}
if !rect.Found || rect.Width <= 0 || rect.Height <= 0 {
return nil, fmt.Errorf("selector %q matched no element with a visible box: %w", selector, ErrScreenshotSelectorNotFound)
}
return &page.Viewport{
X: rect.X,
Y: rect.Y,
Width: rect.Width,
Height: rect.Height,
Scale: 1,
}, nil
}
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.DebugContext(ctx, "set device metrics override")