From 2b8a5402dc976629664b27ff3bf9f9d2388d445f Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 7 Oct 2026 14:44:48 +0200 Subject: [PATCH 1/7] Support /image API --- .gitignore | 3 + README.md | 34 +++++++++++ pkg/api/client.go | 105 +++++++++++++++++++++++++++++++++ pkg/api/client_test.go | 129 +++++++++++++++++++++++++++++++++++++++++ pkg/api/debug.go | 2 +- pkg/cmd/image.go | 85 +++++++++++++++++++++++++++ pkg/cmd/search.go | 19 +++++- tests/e2e_test.go | 108 ++++++++++++++++++++++++++++++++++ 8 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/image.go diff --git a/.gitignore b/.gitignore index 05bf57a..fa34c35 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ .idea/ .vscode/ tmp/ + +# Local secrets +.env diff --git a/README.md b/README.md index 71c549c..ba27a44 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,23 @@ serpapi search engine=google q=coffee --all-pages --max-pages 3 --jq '.organic_r `async=true` returns at once with `search_metadata.id`; collect the results with `serpapi archive`. +#### Image Search (Google Lens) + +- `--image ` — Upload an image with the [Image API](https://serpapi.com/image-api) and search with its `image_id` in one step. Use `-` to read the image from stdin. + +```bash +# Reverse image search with Google Lens +serpapi search engine=google_lens --image ./photo.jpg + +# Pipe an image in from another command +curl -s https://example.com/photo.jpg | serpapi search engine=google_lens --image - + +# Keep only the visual matches +serpapi search engine=google_lens --image ./photo.jpg --jq ".visual_matches[:5]" +``` + +Supported formats are JPG/JPEG, PNG and WebP up to 500 KB. See the [Google Lens image upload docs](https://serpapi.com/google-lens-upload-an-image). + ### archive Fetches an earlier search by its `search_metadata.id`, for free. A search made with `async=true` has `search_metadata.status` `Queued` or `Processing` until it is `Success`, so poll. `output=` takes the same values as in search. @@ -93,6 +110,23 @@ Shows the plan, usage and searches left, for free. The API key in the output is serpapi account --jq .total_searches_left ``` +### image + +Upload an image with the [Image API](https://serpapi.com/image-api) and print its `image_id` without searching. Useful when you want to reuse the same upload across several searches; image IDs expire after 10 minutes. For a one-off search, prefer `serpapi search --image `. + +```bash +# Upload an image file +serpapi image ./photo.jpg + +# Read the image from stdin +curl -s https://example.com/photo.jpg | serpapi image - + +# Reuse the image_id across searches +IMAGE_ID=$(serpapi image ./photo.jpg --jq .image_id) +serpapi search engine=google_lens image_id=$IMAGE_ID +serpapi search engine=google_lens image_id=$IMAGE_ID type=products +``` + ### login Checks an API key with a free account request, then saves it. The key comes from `--api-key`, a hidden prompt on a terminal, or else the first line of stdin, which `login` waits for. diff --git a/pkg/api/client.go b/pkg/api/client.go index c5509ac..c8204f9 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -7,10 +7,14 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net" "net/http" "net/http/httptrace" + "net/textproto" "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -32,6 +36,10 @@ const ( // maxResponseBytes caps the response body; a variable so tests can lower it. var maxResponseBytes = 100 << 20 +// quoteEscaper mirrors the escaping mime/multipart applies to form-data +// header values. +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + // Client is an HTTP client for the SerpApi service. type Client struct { apiKey string @@ -112,6 +120,52 @@ func (c *Client) getTraced(ctx context.Context, endpoint string, params map[stri if err != nil { return nil, &clierrors.NetworkError{Message: "Invalid request: " + endpoint, Cause: err} } + return c.do(req, endpoint, tr) +} + +// doPostMultipart sends a multipart/form-data POST request. params are sent as +// form fields alongside a single file part named fieldName. +func (c *Client) doPostMultipart(ctx context.Context, endpoint string, params map[string]string, fieldName, fileName string, content []byte, tr *tracer) ([]byte, error) { + var body bytes.Buffer + w := multipart.NewWriter(&body) + for k, v := range params { + if err := w.WriteField(k, v); err != nil { + return nil, &clierrors.NetworkError{Message: "Failed to encode request: " + err.Error(), Cause: err} + } + } + + // CreateFormFile hardcodes application/octet-stream, so build the part + // header by hand to send the sniffed image content type. + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + quoteEscaper.Replace(fieldName), quoteEscaper.Replace(fileName))) + h.Set("Content-Type", http.DetectContentType(content)) + part, err := w.CreatePart(h) + if err != nil { + return nil, &clierrors.NetworkError{Message: "Failed to encode request: " + err.Error(), Cause: err} + } + if _, err := part.Write(content); err != nil { + return nil, &clierrors.NetworkError{Message: "Failed to encode request: " + err.Error(), Cause: err} + } + if err := w.Close(); err != nil { + return nil, &clierrors.NetworkError{Message: "Failed to encode request: " + err.Error(), Cause: err} + } + + if tr != nil { + ctx = httptrace.WithClientTrace(ctx, tr.clientTrace()) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+endpoint, &body) + if err != nil { + return nil, &clierrors.NetworkError{Message: "Invalid request: " + endpoint, Cause: err} + } + req.Header.Set("Content-Type", w.FormDataContentType()) + return c.do(req, endpoint, tr) +} + +// do sends req and returns the body of a 200 response verbatim, logging each +// step to tr, which may be nil. +func (c *Client) do(req *http.Request, endpoint string, tr *tracer) ([]byte, error) { req.Header.Set("User-Agent", userAgent()) tr.logRequest(req, c.http.Timeout) @@ -293,6 +347,57 @@ func (c *Client) Locations(ctx context.Context, params map[string]string) (json. return body, nil } +// UploadImage uploads an image file using the Image API. The returned payload +// contains an image_id usable with engines that accept uploaded images, such +// as google_lens. Image IDs expire after 10 minutes. +func (c *Client) UploadImage(ctx context.Context, path string) (json.RawMessage, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, &clierrors.UsageError{Message: "Cannot read image file: " + err.Error()} + } + return c.UploadImageBytes(ctx, content, filepath.Base(path)) +} + +// UploadImageBytes uploads an in-memory image using the Image API. fileName +// is sent as the multipart filename; when empty, one is derived from the +// detected content type. +func (c *Client) UploadImageBytes(ctx context.Context, content []byte, fileName string) (json.RawMessage, error) { + if fileName == "" { + fileName = defaultImageFileName(content) + } + + params := map[string]string{} + if c.apiKey != "" { + params["api_key"] = c.apiKey + } + + body, err := c.doPostMultipart(ctx, "/image", params, "image", fileName, content, c.newTracer()) + if err != nil { + return nil, err + } + if !json.Valid(body) { + return nil, &clierrors.APIError{Message: "Unexpected non-JSON response from server"} + } + if msg := errorMessage(body); msg != "" { + return nil, &clierrors.APIError{Message: msg, Status: http.StatusOK} + } + return json.RawMessage(body), nil +} + +// defaultImageFileName picks a filename with an extension matching the +// sniffed image type, for uploads that have no natural filename (e.g. stdin). +func defaultImageFileName(content []byte) string { + switch http.DetectContentType(content) { + case "image/jpeg": + return "image.jpg" + case "image/png": + return "image.png" + case "image/webp": + return "image.webp" + } + return "image" +} + // Archive retrieves a previous search by ID. func (c *Client) Archive(ctx context.Context, id string) (json.RawMessage, error) { return c.getObject(ctx, archivePath(id), map[string]string{"api_key": c.apiKey}) diff --git a/pkg/api/client_test.go b/pkg/api/client_test.go index 9888b53..01b2296 100644 --- a/pkg/api/client_test.go +++ b/pkg/api/client_test.go @@ -2,11 +2,14 @@ package api import ( "context" + "encoding/json" "errors" "io" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" @@ -442,3 +445,129 @@ func TestTimeoutAdvicePointsToDebugWhenItIsOff(t *testing.T) { } } } + +// smallest valid 1x1 transparent PNG +var png1x1 = []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, +} + +type uploadRequest struct { + method string + path string + apiKey string + fileName string + contentType string + content []byte +} + +func newUploadServer(t *testing.T, status int, body string) (*Client, *uploadRequest) { + t.Helper() + got := &uploadRequest{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.method = r.Method + got.path = r.URL.Path + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Errorf("parse multipart form: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + got.apiKey = r.FormValue("api_key") + file, header, err := r.FormFile("image") + if err != nil { + t.Errorf("missing image part: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + defer file.Close() + got.fileName = header.Filename + got.contentType = header.Header.Get("Content-Type") + got.content, _ = io.ReadAll(file) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + client := New("secret_key") + client.baseURL = srv.URL + return client, got +} + +func TestUploadImageFromFile(t *testing.T) { + client, got := newUploadServer(t, http.StatusOK, `{"message":"Image uploaded successfully.","image_id":"test-image-id"}`) + + path := filepath.Join(t.TempDir(), "photo.png") + if err := os.WriteFile(path, png1x1, 0o600); err != nil { + t.Fatal(err) + } + + raw, err := client.UploadImage(context.Background(), path) + if err != nil { + t.Fatalf("upload failed: %v", err) + } + + var result map[string]string + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if result["image_id"] != "test-image-id" { + t.Errorf("expected image_id test-image-id, got %q", result["image_id"]) + } + + if got.method != http.MethodPost { + t.Errorf("expected POST, got %s", got.method) + } + if got.path != "/image" { + t.Errorf("expected /image path, got %s", got.path) + } + if got.apiKey != "secret_key" { + t.Errorf("expected api_key form field, got %q", got.apiKey) + } + if got.fileName != "photo.png" { + t.Errorf("expected filename photo.png, got %q", got.fileName) + } + if got.contentType != "image/png" { + t.Errorf("expected image/png content type, got %q", got.contentType) + } + if string(got.content) != string(png1x1) { + t.Error("uploaded content does not match the file") + } +} + +func TestUploadImageBytesDerivesFileName(t *testing.T) { + client, got := newUploadServer(t, http.StatusOK, `{"message":"Image uploaded successfully.","image_id":"id"}`) + + if _, err := client.UploadImageBytes(context.Background(), png1x1, ""); err != nil { + t.Fatalf("upload failed: %v", err) + } + if got.fileName != "image.png" { + t.Errorf("expected derived filename image.png, got %q", got.fileName) + } +} + +func TestUploadImageRejectsInvalidImage(t *testing.T) { + client, _ := newUploadServer(t, http.StatusBadRequest, `{"error":"Invalid image format. Supported format: jpg, jpeg, png, webp"}`) + + _, err := client.UploadImageBytes(context.Background(), []byte("invalid image data"), "invalid.txt") + var apiErr *clierrors.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.Message != "Invalid image format. Supported format: jpg, jpeg, png, webp" { + t.Errorf("unexpected message: %q", apiErr.Message) + } +} + +func TestUploadImageMissingFile(t *testing.T) { + client := New("secret_key") + _, err := client.UploadImage(context.Background(), filepath.Join(t.TempDir(), "does-not-exist.png")) + var usageErr *clierrors.UsageError + if !errors.As(err, &usageErr) { + t.Fatalf("expected UsageError, got %T: %v", err, err) + } +} diff --git a/pkg/api/debug.go b/pkg/api/debug.go index 5d4adb4..a2540be 100644 --- a/pkg/api/debug.go +++ b/pkg/api/debug.go @@ -202,7 +202,7 @@ func (t *tracer) logRequest(req *http.Request, timeout time.Duration) { if t == nil { return } - t.logf("GET %s", req.URL.String()) + t.logf("%s %s", req.Method, req.URL.String()) t.logf("User-Agent: %s", req.UserAgent()) if timeout > 0 { t.logf("Timeout: %gs", timeout.Seconds()) diff --git a/pkg/cmd/image.go b/pkg/cmd/image.go new file mode 100644 index 0000000..04154f6 --- /dev/null +++ b/pkg/cmd/image.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/serpapi/serpapi-cli/pkg/api" + clierrors "github.com/serpapi/serpapi-cli/pkg/errors" +) + +var imageCmd = &cobra.Command{ + Use: "image ", + Aliases: []string{"upload-image"}, + Short: "Upload an image for use with image search engines like Google Lens", + Long: `Upload an image with the SerpApi Image API and print the resulting image_id. + +The image_id can be passed to Search API engines that accept uploaded images, +such as google_lens. Supported formats are JPG/JPEG, PNG and WebP up to 500 KB. +Uploaded image IDs expire after 10 minutes. + +To upload and search in a single step, use "serpapi search --image " instead. + +Pass "-" as the file to read the image from standard input.`, + Example: ` serpapi image ./photo.jpg + serpapi image ./photo.jpg --jq .image_id + curl -s https://example.com/photo.jpg | serpapi image -`, + Args: cobra.ExactArgs(1), + RunE: runImage, +} + +func init() { + rootCmd.AddCommand(imageCmd) +} + +func runImage(cmd *cobra.Command, args []string) error { + apiKey, err := resolveAPIKey() + if err != nil { + return err + } + + result, err := uploadImage(cmd.Context(), api.New(apiKey), args[0]) + if err != nil { + return err + } + return handleOutput(cmd, result, nil) +} + +// uploadImage uploads the image at path, or from stdin when path is "-", +// showing a spinner while the request is in flight. +func uploadImage(ctx context.Context, client *api.Client, path string) (json.RawMessage, error) { + var content []byte + if path == "-" { + var err error + content, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, &clierrors.UsageError{Message: "Cannot read image from stdin: " + err.Error()} + } + } + + return withSpinner("Uploading image...", func() (json.RawMessage, error) { + if content != nil { + return client.UploadImageBytes(ctx, content, "") + } + return client.UploadImage(ctx, path) + }) +} + +// uploadImageID uploads an image and returns the image_id from the response. +func uploadImageID(ctx context.Context, client *api.Client, path string) (string, error) { + raw, err := uploadImage(ctx, client, path) + if err != nil { + return "", err + } + var upload struct { + ImageID string `json:"image_id"` + } + if err := json.Unmarshal(raw, &upload); err != nil || upload.ImageID == "" { + return "", &clierrors.APIError{Message: "Image upload response did not include an image_id"} + } + return upload.ImageID, nil +} diff --git a/pkg/cmd/search.go b/pkg/cmd/search.go index b080502..4fb55ee 100644 --- a/pkg/cmd/search.go +++ b/pkg/cmd/search.go @@ -24,6 +24,7 @@ import ( var ( allPagesFlag bool maxPagesFlag int + imageFlag string ) const defaultMaxPages = 100 @@ -83,7 +84,8 @@ with 'serpapi archive ID'.`, serpapi search engine=youtube search_query="lofi beats" --jq ".video_results[:3][].link" serpapi search engine=google_maps q=pizza ll="@40.7455096,-74.0083012,14z" --fields "local_results[0:5]" serpapi search engine=google q=coffee output=md - serpapi search engine=google q=coffee --all-pages --max-pages 3 --jq ".organic_results | length"`, + serpapi search engine=google q=coffee --all-pages --max-pages 3 --jq ".organic_results | length" + serpapi search engine=google_lens --image ./photo.jpg`, Args: cobra.ArbitraryArgs, RunE: runSearch, } @@ -91,6 +93,7 @@ with 'serpapi archive ID'.`, func init() { searchCmd.Flags().BoolVar(&allPagesFlag, "all-pages", false, "follow pagination and merge the arrays of every page") searchCmd.Flags().IntVar(&maxPagesFlag, "max-pages", defaultMaxPages, "page limit for --all-pages; each page is one search") + searchCmd.Flags().StringVar(&imageFlag, "image", "", "Upload an image file (or - for stdin) and search with its image_id, e.g. for engine=google_lens") rootCmd.AddCommand(searchCmd) } @@ -104,6 +107,9 @@ func runSearch(cmd *cobra.Command, args []string) error { } warnRepeated(cmd, parsed) p := params.ParamsToMap(parsed) + if _, exists := p["image_id"]; exists && imageFlag != "" { + return &clierrors.UsageError{Message: "Use either --image or image_id=, not both"} + } maxPagesSet := cmd.Flags().Changed("max-pages") if maxPagesSet && maxPagesFlag < 1 { @@ -137,6 +143,17 @@ func runSearch(cmd *cobra.Command, args []string) error { // Search runs without a key, as in v0.2: the API answers a few demo // queries without one and returns 401 for the rest. apiKey, keyErr := resolveAPIKey() + if imageFlag != "" { + // The upload requires authentication even though search tolerates a missing key. + if keyErr != nil { + return keyErr + } + imageID, err := uploadImageID(cmd.Context(), api.New(apiKey), imageFlag) + if err != nil { + return err + } + p["image_id"] = imageID + } var cfgErr *clierrors.UsageError if keyErr != config.ErrNoAPIKey && errors.As(keyErr, &cfgErr) { warn(cmd, "%s; searching without a key", cfgErr.Message) diff --git a/tests/e2e_test.go b/tests/e2e_test.go index 64d170a..ad0eae1 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -279,6 +279,114 @@ func TestArchive(t *testing.T) { } } +// smallest valid 1x1 transparent PNG +var png1x1 = []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, +} + +func writeTestPNG(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(path, png1x1, 0o600); err != nil { + t.Fatalf("failed to write test image: %v", err) + } + return path +} + +func TestImageUpload(t *testing.T) { + key := requireLive(t) + cmd := exec.Command(binaryPath, "--api-key", key, "image", writeTestPNG(t)) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("image upload failed: %v\n%s", err, out) + } + + var result map[string]any + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("failed to parse upload output: %v", err) + } + if result["message"] != "Image uploaded successfully." { + t.Errorf("unexpected message: %v", result["message"]) + } + if id, _ := result["image_id"].(string); id == "" { + t.Error("expected non-empty image_id") + } +} + +func TestImageUploadFromStdin(t *testing.T) { + key := requireLive(t) + cmd := exec.Command(binaryPath, "--api-key", key, "--jq", ".image_id", "image", "-") + cmd.Stdin = bytes.NewReader(png1x1) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("image upload from stdin failed: %v\n%s", err, out) + } + if len(strings.TrimSpace(string(out))) == 0 { + t.Error("expected non-empty image_id") + } +} + +func TestImageUploadRejectsInvalidImage(t *testing.T) { + key := requireLive(t) + path := filepath.Join(t.TempDir(), "invalid.txt") + if err := os.WriteFile(path, []byte("invalid image data"), 0o600); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(binaryPath, "--api-key", key, "image", path) + var stderr bytes.Buffer + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + t.Fatal("expected failure for invalid image") + } + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 1 { + t.Errorf("expected exit code 1, got %d", exitErr.ExitCode()) + } + if !strings.Contains(stderr.String(), "Invalid image format") { + t.Errorf("expected invalid image format error, got: %s", stderr.String()) + } +} + +func TestImageUploadMissingFile(t *testing.T) { + cmd := exec.Command(binaryPath, "--api-key", "irrelevant", "image", filepath.Join(t.TempDir(), "missing.png")) + err := cmd.Run() + if err == nil { + t.Fatal("expected failure for missing file") + } + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 2 { + t.Errorf("expected exit code 2, got %d", exitErr.ExitCode()) + } +} + +func TestSearchWithImage(t *testing.T) { + key := requireLive(t) + cmd := exec.Command(binaryPath, "--api-key", key, "search", "engine=google_lens", "--image", writeTestPNG(t), + "--jq", ".search_parameters.image_id") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("search with --image failed: %v\n%s", err, out) + } + if len(strings.TrimSpace(string(out))) == 0 { + t.Error("expected the uploaded image_id in search_parameters") + } +} + +func TestSearchWithImageConflictsWithImageID(t *testing.T) { + cmd := exec.Command(binaryPath, "--api-key", "irrelevant", "search", "engine=google_lens", "image_id=abc", "--image", writeTestPNG(t)) + err := cmd.Run() + if err == nil { + t.Fatal("expected failure when both --image and image_id are given") + } + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 2 { + t.Errorf("expected exit code 2, got %d", exitErr.ExitCode()) + } +} + func TestSearchAllPages(t *testing.T) { key := requireLive(t) res := serpapi(t, "", "--api-key", key, "search", "engine=google", "q=coffee", From 0b1ac1e5a4f1451eab69c8ea11c68339a4005b57 Mon Sep 17 00:00:00 2001 From: ilyazub Date: Tue, 13 Oct 2026 15:03:55 +0200 Subject: [PATCH 2/7] [Image] Upload with the client and stdin every command uses image and search --image built their client with api.New, so --timeout and SERPAPI_TIMEOUT didn't apply to uploads and tests couldn't point them at a fake API, and they read os.Stdin rather than the command's stdin. They now use newClient and cmd.InOrStdin(), as search and login do; of 6 new tests against the fake API, 4 failed before, sending to serpapi.com. Co-Authored-By: Claude Opus 5.5 --- pkg/cmd/image.go | 13 ++-- pkg/cmd/image_test.go | 145 ++++++++++++++++++++++++++++++++++++++++++ pkg/cmd/search.go | 2 +- 3 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 pkg/cmd/image_test.go diff --git a/pkg/cmd/image.go b/pkg/cmd/image.go index 04154f6..9fcb999 100644 --- a/pkg/cmd/image.go +++ b/pkg/cmd/image.go @@ -1,10 +1,8 @@ package cmd import ( - "context" "encoding/json" "io" - "os" "github.com/spf13/cobra" @@ -42,7 +40,7 @@ func runImage(cmd *cobra.Command, args []string) error { return err } - result, err := uploadImage(cmd.Context(), api.New(apiKey), args[0]) + result, err := uploadImage(cmd, newClient(apiKey), args[0]) if err != nil { return err } @@ -51,11 +49,12 @@ func runImage(cmd *cobra.Command, args []string) error { // uploadImage uploads the image at path, or from stdin when path is "-", // showing a spinner while the request is in flight. -func uploadImage(ctx context.Context, client *api.Client, path string) (json.RawMessage, error) { +func uploadImage(cmd *cobra.Command, client *api.Client, path string) (json.RawMessage, error) { + ctx := cmd.Context() var content []byte if path == "-" { var err error - content, err = io.ReadAll(os.Stdin) + content, err = io.ReadAll(cmd.InOrStdin()) if err != nil { return nil, &clierrors.UsageError{Message: "Cannot read image from stdin: " + err.Error()} } @@ -70,8 +69,8 @@ func uploadImage(ctx context.Context, client *api.Client, path string) (json.Raw } // uploadImageID uploads an image and returns the image_id from the response. -func uploadImageID(ctx context.Context, client *api.Client, path string) (string, error) { - raw, err := uploadImage(ctx, client, path) +func uploadImageID(cmd *cobra.Command, client *api.Client, path string) (string, error) { + raw, err := uploadImage(cmd, client, path) if err != nil { return "", err } diff --git a/pkg/cmd/image_test.go b/pkg/cmd/image_test.go new file mode 100644 index 0000000..34ccd05 --- /dev/null +++ b/pkg/cmd/image_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +const imageKey = "image_test_key" + +// png1x1 is the smallest valid PNG, one transparent pixel. +var png1x1 = []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, +} + +// imageUpload is what the fake Image API received. +type imageUpload struct { + fileName, contentType, apiKey string + size int +} + +// imageAPI fakes /image, which answers with an image_id, and /search.json, +// which echoes the image_id it was sent. uploads returns what /image got. +func imageAPI(t *testing.T) (reqs *requests, uploads func() []imageUpload) { + t.Helper() + var mu sync.Mutex + var got []imageUpload + reqs = fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/image" { + fmt.Fprintf(w, `{"search_metadata":{"id":"s1"},"search_parameters":{"image_id":%q}}`, r.URL.Query().Get("image_id")) + return + } + u := imageUpload{apiKey: r.FormValue("api_key")} + if f, h, err := r.FormFile("image"); err == nil { + b, _ := io.ReadAll(f) + u.fileName, u.contentType, u.size = h.Filename, h.Header.Get("Content-Type"), len(b) + } + mu.Lock() + got = append(got, u) + mu.Unlock() + fmt.Fprint(w, `{"message":"Image uploaded successfully.","image_id":"img123"}`) + }) + return reqs, func() []imageUpload { + mu.Lock() + defer mu.Unlock() + return append([]imageUpload(nil), got...) + } +} + +func writeImage(t *testing.T, name string, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestImageUploadsAFileAndPrintsTheResponse(t *testing.T) { + reqs, uploads := imageAPI(t) + + res := execute(t, "", "--api-key", imageKey, "image", writeImage(t, "photo.png", png1x1)) + + wantOK(t, res) + var out map[string]string + if err := json.Unmarshal([]byte(res.stdout), &out); err != nil || out["image_id"] != "img123" { + t.Errorf("stdout = %q, want the upload's JSON with its image_id", res.stdout) + } + if u := reqs.only(t); u.Path != "/image" || u.Query().Has("api_key") { + t.Errorf("request = %s, want /image with the key in the form, not the URL", u) + } + want := imageUpload{fileName: "photo.png", contentType: "image/png", apiKey: imageKey, size: len(png1x1)} + if got := uploads(); len(got) != 1 || got[0] != want { + t.Errorf("uploads = %+v, want %+v", got, want) + } +} + +func TestImageReadsStdin(t *testing.T) { + _, uploads := imageAPI(t) + + res := execute(t, string(png1x1), "--api-key", imageKey, "image", "-") + + wantOK(t, res) + want := imageUpload{fileName: "image.png", contentType: "image/png", apiKey: imageKey, size: len(png1x1)} + if got := uploads(); len(got) != 1 || got[0] != want { + t.Errorf("uploads = %+v, want %+v", got, want) + } +} + +func TestSearchWithImageSendsItsID(t *testing.T) { + reqs, _ := imageAPI(t) + + res := execute(t, "", "--api-key", imageKey, "search", "engine=google_lens", "--image", writeImage(t, "photo.png", png1x1), + "--jq", ".search_parameters.image_id") + + wantOK(t, res) + if got := strings.TrimSpace(res.stdout); got != "img123" { + t.Errorf("stdout = %q, want the uploaded image_id", got) + } + urls := reqs.list() + if len(urls) != 2 || urls[0].Path != "/image" { + t.Fatalf("requests = %v, want the upload, then the search", urls) + } + wantQuery(t, urls[1], "/search.json", map[string]string{"engine": "google_lens", "image_id": "img123", "api_key": imageKey}) +} + +func TestImageUploadTimesOutAsSet(t *testing.T) { + // Until it has read the body, the server can't notice the client give up. + fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + hang(w, r) + }) + + res := execute(t, "", "--api-key", imageKey, "--timeout", "0.05", "image", writeImage(t, "photo.png", png1x1)) + + wantError(t, res, 1, "network_error", "Request to /image timed out after 50ms") +} + +func TestImageNeedsAKeyBeforeUploading(t *testing.T) { + for _, args := range [][]string{{"image"}, {"search", "engine=google_lens", "--image"}} { + t.Run(args[0], func(t *testing.T) { + fakeAPI(t, nil) + res := execute(t, "", append(args, writeImage(t, "photo.png", png1x1))...) + wantError(t, res, 2, "usage_error", "No API key") + }) + } +} + +func TestImageWithImageIDIsAUsageError(t *testing.T) { + fakeAPI(t, nil) + + res := execute(t, "", "--api-key", imageKey, "search", "engine=google_lens", "image_id=abc", "--image", writeImage(t, "photo.png", png1x1)) + + wantError(t, res, 2, "usage_error", "Use either --image or image_id=, not both") +} diff --git a/pkg/cmd/search.go b/pkg/cmd/search.go index 4fb55ee..c5c6cd2 100644 --- a/pkg/cmd/search.go +++ b/pkg/cmd/search.go @@ -148,7 +148,7 @@ func runSearch(cmd *cobra.Command, args []string) error { if keyErr != nil { return keyErr } - imageID, err := uploadImageID(cmd.Context(), api.New(apiKey), imageFlag) + imageID, err := uploadImageID(cmd, newClient(apiKey), imageFlag) if err != nil { return err } From e0a949db255114384d964f6f8337df114efe6a9d Mon Sep 17 00:00:00 2001 From: ilyazub Date: Tue, 13 Oct 2026 15:05:16 +0200 Subject: [PATCH 3/7] [Image] Refuse an empty image or one over 500 KB before uploading The Image API takes up to 500 KB, but image and search --image read the file or stdin whole and uploaded it, empty too, for the API to reject (exit 1). The read now stops one byte past 500 KiB, and an empty or larger image is a usage error, exit 2, before any request: of 5 such cases in the new tests, all 5 were uploaded before, none now. Co-Authored-By: Claude Opus 5.5 --- pkg/api/client.go | 23 +++++++++++++++++++++- pkg/api/client_test.go | 43 ++++++++++++++++++++++++++++++++++++++++++ pkg/cmd/image.go | 3 +-- pkg/cmd/image_test.go | 22 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/pkg/api/client.go b/pkg/api/client.go index c8204f9..701440c 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -351,17 +351,38 @@ func (c *Client) Locations(ctx context.Context, params map[string]string) (json. // contains an image_id usable with engines that accept uploaded images, such // as google_lens. Image IDs expire after 10 minutes. func (c *Client) UploadImage(ctx context.Context, path string) (json.RawMessage, error) { - content, err := os.ReadFile(path) + f, err := os.Open(path) + if err != nil { + return nil, &clierrors.UsageError{Message: "Cannot read image file: " + err.Error()} + } + defer f.Close() + content, err := ReadImage(f) if err != nil { return nil, &clierrors.UsageError{Message: "Cannot read image file: " + err.Error()} } return c.UploadImageBytes(ctx, content, filepath.Base(path)) } +// MaxImageSize is the Image API's 500 KB limit, read as 500 KiB so the CLI +// never refuses an image the API would take. +const MaxImageSize = 500 << 10 + +// ReadImage reads an image to upload, stopping one byte past MaxImageSize: +// UploadImageBytes refuses a larger one, which needn't be read whole. +func ReadImage(r io.Reader) ([]byte, error) { + return io.ReadAll(io.LimitReader(r, MaxImageSize+1)) +} + // UploadImageBytes uploads an in-memory image using the Image API. fileName // is sent as the multipart filename; when empty, one is derived from the // detected content type. func (c *Client) UploadImageBytes(ctx context.Context, content []byte, fileName string) (json.RawMessage, error) { + switch { + case len(content) == 0: + return nil, &clierrors.UsageError{Message: "The image is empty"} + case len(content) > MaxImageSize: + return nil, &clierrors.UsageError{Message: "The image is over 500 KB, the Image API's limit; resize or compress it"} + } if fileName == "" { fileName = defaultImageFileName(content) } diff --git a/pkg/api/client_test.go b/pkg/api/client_test.go index 01b2296..b7a1500 100644 --- a/pkg/api/client_test.go +++ b/pkg/api/client_test.go @@ -571,3 +571,46 @@ func TestUploadImageMissingFile(t *testing.T) { t.Fatalf("expected UsageError, got %T: %v", err, err) } } + +func TestUploadImageRefusesEmptyAndOversizedImagesUnsent(t *testing.T) { + srv := newTestServer(t, func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to %s", r.URL.Path) + }) + client := New("secret_key", WithBaseURL(srv.URL)) + for name, content := range map[string][]byte{"empty": {}, "one byte over": make([]byte, MaxImageSize+1)} { + _, err := client.UploadImageBytes(context.Background(), content, "photo.png") + var usageErr *clierrors.UsageError + if !errors.As(err, &usageErr) { + t.Errorf("%s: got %v, want a UsageError", name, err) + } + } +} + +func TestUploadImageSendsOneAtTheLimit(t *testing.T) { + client, got := newUploadServer(t, http.StatusOK, `{"image_id":"id"}`) + content := append(append([]byte{}, png1x1...), make([]byte, MaxImageSize-len(png1x1))...) + + if _, err := client.UploadImageBytes(context.Background(), content, "photo.png"); err != nil { + t.Fatalf("upload at the limit: %v", err) + } + if len(got.content) != MaxImageSize { + t.Errorf("uploaded %d bytes, want %d", len(got.content), MaxImageSize) + } +} + +// endless reads zeros forever and counts them. +type endless struct{ n int } + +func (e *endless) Read(p []byte) (int, error) { + clear(p) + e.n += len(p) + return len(p), nil +} + +func TestReadImageStopsPastTheLimit(t *testing.T) { + r := &endless{} + content, err := ReadImage(r) + if err != nil || len(content) != MaxImageSize+1 || r.n != MaxImageSize+1 { + t.Errorf("read %d bytes and kept %d (err %v), want both %d", r.n, len(content), err, MaxImageSize+1) + } +} diff --git a/pkg/cmd/image.go b/pkg/cmd/image.go index 9fcb999..fe93a39 100644 --- a/pkg/cmd/image.go +++ b/pkg/cmd/image.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/json" - "io" "github.com/spf13/cobra" @@ -54,7 +53,7 @@ func uploadImage(cmd *cobra.Command, client *api.Client, path string) (json.RawM var content []byte if path == "-" { var err error - content, err = io.ReadAll(cmd.InOrStdin()) + content, err = api.ReadImage(cmd.InOrStdin()) if err != nil { return nil, &clierrors.UsageError{Message: "Cannot read image from stdin: " + err.Error()} } diff --git a/pkg/cmd/image_test.go b/pkg/cmd/image_test.go index 34ccd05..bb1a945 100644 --- a/pkg/cmd/image_test.go +++ b/pkg/cmd/image_test.go @@ -143,3 +143,25 @@ func TestImageWithImageIDIsAUsageError(t *testing.T) { wantError(t, res, 2, "usage_error", "Use either --image or image_id=, not both") } + +func TestImageRefusesEmptyAndOversizedImagesUnsent(t *testing.T) { + big := append(append([]byte{}, png1x1...), make([]byte, 600<<10)...) + for _, tc := range []struct { + name, stdin, msg string + args func(t *testing.T) []string + }{ + {"empty file", "", "The image is empty", func(t *testing.T) []string { return []string{"image", writeImage(t, "empty.png", nil)} }}, + {"empty stdin", "", "The image is empty", func(t *testing.T) []string { return []string{"image", "-"} }}, + {"600 KB file", "", "over 500 KB", func(t *testing.T) []string { return []string{"image", writeImage(t, "big.png", big)} }}, + {"600 KB on stdin", string(big), "over 500 KB", func(t *testing.T) []string { return []string{"image", "-"} }}, + {"600 KB for a search", "", "over 500 KB", func(t *testing.T) []string { + return []string{"search", "engine=google_lens", "--image", writeImage(t, "big.png", big)} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + fakeAPI(t, nil) + res := execute(t, tc.stdin, append([]string{"--api-key", imageKey}, tc.args(t)...)...) + wantError(t, res, 2, "usage_error", tc.msg) + }) + } +} From d9544cb4de0a8425ca5f907ade80b4fa458efe13 Mon Sep 17 00:00:00 2001 From: ilyazub Date: Tue, 13 Oct 2026 15:06:15 +0200 Subject: [PATCH 4/7] [Image] Stop at once on Ctrl-C while reading the image from stdin image - read stdin with nothing watching for the signal, so Ctrl-C ended it only at the forced exit 3s later, "the output may be cut off". The read now stops on the signal as login's does: exit 130 after 0.0s instead of 3.1s on a terminal. The README says an image given as - reads stdin too. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- pkg/cmd/image.go | 7 ++++++- pkg/cmd/image_test.go | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba27a44..2187af0 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ A config file that can't be read or isn't valid TOML is reported by path and lin | 130 | `interrupted` | Ctrl-C or SIGTERM stopped it before it finished, after printing what had arrived, such as the `--all-pages` pages fetched so far. | - **Cost**: each successful search counts against your monthly searches, including each `--all-pages` page; cached, errored and failed ones don't, though errored ones count toward the hourly limit ([pricing FAQ](https://serpapi.com/pricing)). An identical search, `--fields` included, repeated within an hour comes from the cache. `no_cache=true` forces a new, counted search; cached results are at most an hour old, so add it only when that is too old. `zero_trace=true` (Enterprise) stores nothing, so it skips the cache too and leaves no search for `archive` to read. `account`, `locations` and `archive` are free. -- **No prompts without a terminal.** Only `serpapi login` reads stdin, and it waits for the first line, so pipe the key in (`printf %s "$KEY" | serpapi login`) or, simpler, set `SERPAPI_KEY`. +- **No prompts without a terminal.** Only `serpapi login` and an image given as `-` (`serpapi image -`, `search --image -`) read stdin. `login` waits for the first line, so pipe the key in (`printf %s "$KEY" | serpapi login`) or, simpler, set `SERPAPI_KEY`; an image is read until stdin closes. ## Links diff --git a/pkg/cmd/image.go b/pkg/cmd/image.go index fe93a39..263fff6 100644 --- a/pkg/cmd/image.go +++ b/pkg/cmd/image.go @@ -52,8 +52,13 @@ func uploadImage(cmd *cobra.Command, client *api.Client, path string) (json.RawM ctx := cmd.Context() var content []byte if path == "-" { + in := cmd.InOrStdin() var err error - content, err = api.ReadImage(cmd.InOrStdin()) + content, err = readInterruptibly(ctx, func() ([]byte, error) { return api.ReadImage(in) }, + "Stopped while reading the image from stdin; nothing was uploaded") + if isInterrupted(err) { + return nil, err + } if err != nil { return nil, &clierrors.UsageError{Message: "Cannot read image from stdin: " + err.Error()} } diff --git a/pkg/cmd/image_test.go b/pkg/cmd/image_test.go index bb1a945..76e424b 100644 --- a/pkg/cmd/image_test.go +++ b/pkg/cmd/image_test.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "encoding/json" "fmt" "io" @@ -10,6 +11,7 @@ import ( "strings" "sync" "testing" + "time" ) const imageKey = "image_test_key" @@ -165,3 +167,20 @@ func TestImageRefusesEmptyAndOversizedImagesUnsent(t *testing.T) { }) } } + +func TestImageStopsAtOnceWhenInterruptedOnStdin(t *testing.T) { + fakeAPI(t, nil) + stdin, writer := io.Pipe() + t.Cleanup(func() { writer.Close() }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + time.AfterFunc(50*time.Millisecond, cancel) + + start := time.Now() + res := executeInput(t, ctx, stdin, "--api-key", imageKey, "image", "-") + + wantError(t, res, 130, "interrupted", "Stopped while reading the image from stdin; nothing was uploaded") + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("image took %v to stop, want well under the %v grace period", elapsed, interruptGrace) + } +} From dfa8de96f1d85b6e8dd0e666002f1638bd346a0a Mon Sep 17 00:00:00 2001 From: ilyazub Date: Tue, 13 Oct 2026 16:24:47 +0200 Subject: [PATCH 5/7] [Image] Say only how to retry when an upload times out A timed-out upload got the search advice: slow engines such as google_ai_mode, and that the search may still finish, count against your searches and be in the archive. An upload has no engine, search or archive entry, so /image now says only how to retry with a higher --timeout. Searches and every other endpoint keep the message byte for byte. Co-Authored-By: Claude Opus 5.5 --- pkg/api/client.go | 8 ++++++++ pkg/api/client_test.go | 22 +++++++++++++++++++++ pkg/cmd/image_test.go | 44 +++++++++++++++++++++++++++++++++--------- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/pkg/api/client.go b/pkg/api/client.go index 701440c..ffebeba 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -210,6 +210,14 @@ func (c *Client) networkError(endpoint string, err error) error { if c.debug == nil { debugHint = "; --debug shows which step stalled" } + // An upload has no engine, search or archive entry to advise on. + if endpoint == "/image" { + return &clierrors.NetworkError{ + Message: fmt.Sprintf("Request to %s timed out after %s: retry with a higher --timeout, such as --timeout %d, or 0 to wait indefinitely%s", + endpoint, seconds(c.http.Timeout), suggestedTimeout(c.http.Timeout), debugHint), + Cause: cause, + } + } return &clierrors.NetworkError{ Message: fmt.Sprintf("Request to %s timed out after %s. Slow engines, such as google_ai_mode, and no_cache=true can take longer: "+ "retry with a higher --timeout, such as --timeout %d, or 0 to wait indefinitely%s. "+ diff --git a/pkg/api/client_test.go b/pkg/api/client_test.go index b7a1500..918a013 100644 --- a/pkg/api/client_test.go +++ b/pkg/api/client_test.go @@ -614,3 +614,25 @@ func TestReadImageStopsPastTheLimit(t *testing.T) { t.Errorf("read %d bytes and kept %d (err %v), want both %d", r.n, len(content), err, MaxImageSize+1) } } + +// An upload has no engine, search or archive entry, so its timeout advice +// says only how to retry; every other endpoint keeps the search advice. +func TestUploadTimeoutAdviceLeavesOutTheSearch(t *testing.T) { + retry := "retry with a higher --timeout, such as --timeout 120, or 0 to wait indefinitely; --debug shows which step stalled" + search := func(endpoint string) string { + return "Request to " + endpoint + " timed out after 50ms. Slow engines, such as google_ai_mode, and no_cache=true can take longer: " + + retry + ". The search may still finish on SerpApi's side, count against your searches and be available in your search archive" + } + for endpoint, want := range map[string]string{ + "/image": "Request to /image timed out after 50ms: " + retry, + "/search.json": search("/search.json"), + "/account.json": search("/account.json"), + "/locations.json": search("/locations.json"), + archivePath("abc"): search(archivePath("abc")), + } { + var netErr *clierrors.NetworkError + if err := New("secret", WithTimeout(50*time.Millisecond)).networkError(endpoint, osTimeout{}); !errors.As(err, &netErr) || netErr.Message != want { + t.Errorf("%s: got %v\nwant %s", endpoint, err, want) + } + } +} diff --git a/pkg/cmd/image_test.go b/pkg/cmd/image_test.go index 76e424b..6e561d4 100644 --- a/pkg/cmd/image_test.go +++ b/pkg/cmd/image_test.go @@ -116,16 +116,42 @@ func TestSearchWithImageSendsItsID(t *testing.T) { wantQuery(t, urls[1], "/search.json", map[string]string{"engine": "google_lens", "image_id": "img123", "api_key": imageKey}) } -func TestImageUploadTimesOutAsSet(t *testing.T) { - // Until it has read the body, the server can't notice the client give up. - fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { - _, _ = io.Copy(io.Discard, r.Body) - hang(w, r) - }) - - res := execute(t, "", "--api-key", imageKey, "--timeout", "0.05", "image", writeImage(t, "photo.png", png1x1)) +// An upload has no engine, search or archive entry, so a timed-out upload +// gets only the retry advice, while the search after it keeps the search's. +func TestImageTimeoutAdviceFitsTheStep(t *testing.T) { + const upload = "Request to /image timed out after 50ms: retry with a higher --timeout, such as --timeout 120, or 0 to wait indefinitely" + const search = "Request to /search.json timed out after 50ms. Slow engines, such as google_ai_mode" + for _, tc := range []struct { + name, slow, msg string + args []string + }{ + {"image", "/image", upload, []string{"image"}}, + {"search --image, slow upload", "/image", upload, []string{"search", "engine=google_lens", "--image"}}, + {"search --image, slow search", "/search.json", search, []string{"search", "engine=google_lens", "--image"}}, + } { + t.Run(tc.name, func(t *testing.T) { + fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { + // Until it has read the body, the server can't notice the client give up. + _, _ = io.Copy(io.Discard, r.Body) + if r.URL.Path == tc.slow { + hang(w, r) + return + } + fmt.Fprint(w, `{"image_id":"img123"}`) + }) + + args := append([]string{"--api-key", imageKey, "--timeout", "0.05"}, tc.args...) + res := execute(t, "", append(args, writeImage(t, "photo.png", png1x1))...) - wantError(t, res, 1, "network_error", "Request to /image timed out after 50ms") + msg, _ := wantError(t, res, 1, "network_error", tc.msg)["message"].(string) + wantSearchAdvice := tc.slow == "/search.json" + for _, part := range []string{"Slow engines", "The search may still finish on SerpApi's side"} { + if strings.Contains(msg, part) != wantSearchAdvice { + t.Errorf("message %q: has %q = %v, want %v", msg, part, !wantSearchAdvice, wantSearchAdvice) + } + } + }) + } } func TestImageNeedsAKeyBeforeUploading(t *testing.T) { From be7762df5b906f39b69169774f90b582c324109e Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 14 Oct 2026 14:10:45 +0200 Subject: [PATCH 6/7] [Image] Test that uploads are traced as POST /image Nothing else checks that an upload shows up in --debug at all: this asserts it is traced as POST /image with its connection timings, never as a GET, and without the API key. --- pkg/api/debug_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/api/debug_test.go b/pkg/api/debug_test.go index 817bc89..0c9878d 100644 --- a/pkg/api/debug_test.go +++ b/pkg/api/debug_test.go @@ -373,3 +373,36 @@ func TestDebugNoMetadataLineForNonSearchEndpoints(t *testing.T) { t.Errorf("account endpoint should not emit search correlation:\n%s", buf.String()) } } + +// An upload is traced like a search: as the POST it is, with the connection +// lifecycle, and without the API key, which travels in the multipart body. +func TestDebugTracesUploadAsPost(t *testing.T) { + var buf bytes.Buffer + srv := newTestServer(t, func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write([]byte(`{"image_id":"img1"}`)) + }) + client := New("secret_key", WithBaseURL(srv.URL), WithTimeout(5*time.Second), WithDebug(&buf, false)) + + if _, err := client.UploadImageBytes(context.Background(), png1x1, "photo.png"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "POST " + srv.URL + "/image", + "Timeout: 5s", + "Connecting:", + "Using connection:", + "Request sent, waiting for response headers", + "Response: HTTP/1.1 200 OK", + "Body received: 19 bytes", + } { + if !strings.Contains(out, want) { + t.Errorf("debug output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "GET ") || strings.Contains(out, "secret_key") { + t.Errorf("upload traced as a GET or with the key:\n%s", out) + } +} From 778b66c841a4b4f10f2e249aafc74bdbb96371ea Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 14 Oct 2026 14:39:27 +0200 Subject: [PATCH 7/7] Refuse --image flag for non-supported engines --- README.md | 2 +- pkg/cmd/image.go | 3 ++- pkg/cmd/image_test.go | 22 ++++++++++++++++++++++ pkg/cmd/search.go | 11 ++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2187af0..261db49 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ serpapi search engine=google q=coffee --all-pages --max-pages 3 --jq '.organic_r #### Image Search (Google Lens) -- `--image ` — Upload an image with the [Image API](https://serpapi.com/image-api) and search with its `image_id` in one step. Use `-` to read the image from stdin. +- `--image ` — Upload an image with the [Image API](https://serpapi.com/image-api) and search with its `image_id` in one step. Use `-` to read the image from stdin. Only `engine=google_lens` takes an `image_id`, so other engines are refused before the upload; to pass an upload to another engine, use [`serpapi image`](#image) and `image_id=`. ```bash # Reverse image search with Google Lens diff --git a/pkg/cmd/image.go b/pkg/cmd/image.go index 263fff6..b1bee4c 100644 --- a/pkg/cmd/image.go +++ b/pkg/cmd/image.go @@ -19,7 +19,8 @@ The image_id can be passed to Search API engines that accept uploaded images, such as google_lens. Supported formats are JPG/JPEG, PNG and WebP up to 500 KB. Uploaded image IDs expire after 10 minutes. -To upload and search in a single step, use "serpapi search --image " instead. +To upload and search Google Lens in one step, use +"serpapi search engine=google_lens --image " instead. Pass "-" as the file to read the image from standard input.`, Example: ` serpapi image ./photo.jpg diff --git a/pkg/cmd/image_test.go b/pkg/cmd/image_test.go index 6e561d4..f494b94 100644 --- a/pkg/cmd/image_test.go +++ b/pkg/cmd/image_test.go @@ -172,6 +172,28 @@ func TestImageWithImageIDIsAUsageError(t *testing.T) { wantError(t, res, 2, "usage_error", "Use either --image or image_id=, not both") } +// Only Google Lens takes image_id, so --image with any other engine, or the +// default google, is refused before the upload rather than after it. +func TestSearchWithImageRefusesOtherEnginesUnsent(t *testing.T) { + for _, tc := range []struct { + name string + args []string + msg string + }{ + {"no engine", nil, "--image needs engine=google_lens"}, + {"empty engine", []string{"engine="}, "--image needs engine=google_lens"}, + {"engine=google", []string{"engine=google"}, "not engine=google; for another engine, upload with 'serpapi image FILE'"}, + {"engine=bing_reverse_image", []string{"engine=bing_reverse_image"}, "not engine=bing_reverse_image"}, + } { + t.Run(tc.name, func(t *testing.T) { + fakeAPI(t, nil) + args := append([]string{"--api-key", imageKey, "search", "q=cat"}, tc.args...) + res := execute(t, "", append(args, "--image", writeImage(t, "photo.png", png1x1))...) + wantError(t, res, 2, "usage_error", tc.msg) + }) + } +} + func TestImageRefusesEmptyAndOversizedImagesUnsent(t *testing.T) { big := append(append([]byte{}, png1x1...), make([]byte, 600<<10)...) for _, tc := range []struct { diff --git a/pkg/cmd/search.go b/pkg/cmd/search.go index c5c6cd2..31a9b3c 100644 --- a/pkg/cmd/search.go +++ b/pkg/cmd/search.go @@ -93,7 +93,7 @@ with 'serpapi archive ID'.`, func init() { searchCmd.Flags().BoolVar(&allPagesFlag, "all-pages", false, "follow pagination and merge the arrays of every page") searchCmd.Flags().IntVar(&maxPagesFlag, "max-pages", defaultMaxPages, "page limit for --all-pages; each page is one search") - searchCmd.Flags().StringVar(&imageFlag, "image", "", "Upload an image file (or - for stdin) and search with its image_id, e.g. for engine=google_lens") + searchCmd.Flags().StringVar(&imageFlag, "image", "", "Upload an image file (or - for stdin) and search with its image_id; needs engine=google_lens") rootCmd.AddCommand(searchCmd) } @@ -110,6 +110,15 @@ func runSearch(cmd *cobra.Command, args []string) error { if _, exists := p["image_id"]; exists && imageFlag != "" { return &clierrors.UsageError{Message: "Use either --image or image_id=, not both"} } + // Only Google Lens documents image_id; other engines would ignore it and + // run an unrelated search after the upload. + if e := p["engine"]; imageFlag != "" && e != "google_lens" { + if e == "" { + return &clierrors.UsageError{Message: "--image needs engine=google_lens"} + } + return &clierrors.UsageError{Message: "--image works only with engine=google_lens, not engine=" + e + + "; for another engine, upload with 'serpapi image FILE' and pass image_id="} + } maxPagesSet := cmd.Flags().Changed("max-pages") if maxPagesSet && maxPagesFlag < 1 {