From 0f162242a687b7161f80d224da57c11631e28abd Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 7 Oct 2026 14:44:48 +0200 Subject: [PATCH] Support /image API --- .gitignore | 3 + README.md | 34 ++++++++++ pkg/api/client.go | 99 +++++++++++++++++++++++++++++ pkg/api/client_test.go | 141 +++++++++++++++++++++++++++++++++++++++++ pkg/cmd/image.go | 86 +++++++++++++++++++++++++ pkg/cmd/search.go | 20 +++++- tests/e2e_test.go | 109 +++++++++++++++++++++++++++++++ 7 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 pkg/api/client_test.go 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 53590cf..a25ab91 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,23 @@ serpapi search engine=google q=coffee --all-pages serpapi search engine=google q=coffee --all-pages --max-pages 3 ``` +#### 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). + ### account Retrieve account information and usage statistics. @@ -95,6 +112,23 @@ Retrieve a previously cached search by ID. serpapi archive ``` +### 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 Interactive authentication flow to save API key to config file. diff --git a/pkg/api/client.go b/pkg/api/client.go index 1c9654d..e9fa325 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -6,8 +6,13 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "net/url" + "os" + "path/filepath" + "strings" "time" clierrors "github.com/serpapi/serpapi-cli/pkg/errors" @@ -19,6 +24,10 @@ const ( maxResponseBytes = 100 << 20 // 100 MB ) +// 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 @@ -60,6 +69,48 @@ func (c *Client) doGet(ctx context.Context, endpoint string, params map[string]s if err != nil { return nil, &clierrors.NetworkError{Message: err.Error(), Cause: err} } + return c.do(req) +} + +// 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) ([]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} + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+endpoint, &body) + if err != nil { + return nil, &clierrors.NetworkError{Message: err.Error(), Cause: err} + } + req.Header.Set("Content-Type", w.FormDataContentType()) + return c.do(req) +} + +// do executes the request and returns the body after validating that the +// response is a successful JSON payload. +func (c *Client) do(req *http.Request) ([]byte, error) { req.Header.Set("User-Agent", c.userAgent()) resp, err := c.http.Do(req) @@ -165,6 +216,54 @@ func (c *Client) Locations(ctx context.Context, params map[string]string) (json. return json.RawMessage(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) + if err != nil { + return nil, err + } + if err := checkAPIError(body); err != nil { + return nil, err + } + 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 previously cached search result by ID. func (c *Client) Archive(ctx context.Context, id string) (json.RawMessage, error) { params := map[string]string{"api_key": c.apiKey} diff --git a/pkg/api/client_test.go b/pkg/api/client_test.go new file mode 100644 index 0000000..8785a0f --- /dev/null +++ b/pkg/api/client_test.go @@ -0,0 +1,141 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + clierrors "github.com/serpapi/serpapi-cli/pkg/errors" +) + +// 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/cmd/image.go b/pkg/cmd/image.go new file mode 100644 index 0000000..eb9d7c6 --- /dev/null +++ b/pkg/cmd/image.go @@ -0,0 +1,86 @@ +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(result) +} + +// 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()} + } + } + + sp := newSpinner("Uploading image...") + sp.Start() + defer sp.Stop() + 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 b42216f..b208be0 100644 --- a/pkg/cmd/search.go +++ b/pkg/cmd/search.go @@ -18,6 +18,7 @@ import ( var ( allPagesFlag bool maxPagesFlag int + imageFlag string ) const ( @@ -32,7 +33,8 @@ var searchCmd = &cobra.Command{ serpapi search engine=google_light q="weather in Tokyo" serpapi search engine=google_maps q="pizza" ll="@40.7455096,-74.0083012,14z" serpapi search engine=google q=coffee --jq ".organic_results[:3]" - serpapi search engine=google q=coffee --all-pages --max-pages 3`, + serpapi search engine=google q=coffee --all-pages --max-pages 3 + serpapi search engine=google_lens --image ./photo.jpg`, Args: cobra.ArbitraryArgs, RunE: runSearch, } @@ -40,6 +42,7 @@ var searchCmd = &cobra.Command{ func init() { searchCmd.Flags().BoolVar(&allPagesFlag, "all-pages", false, "Fetch all pages and merge array results") searchCmd.Flags().IntVar(&maxPagesFlag, "max-pages", 0, "Maximum number of pages to fetch when paginating with --all-pages") + 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) } @@ -53,6 +56,21 @@ func runSearch(cmd *cobra.Command, args []string) error { paramsMap := params.ParamsToMap(parsed) params.ApplyFields(paramsMap, fieldsFlag) + if imageFlag != "" { + if _, exists := paramsMap["image_id"]; exists { + return &clierrors.UsageError{Message: "Use either --image or image_id=, not both"} + } + // The upload requires authentication even though search tolerates a missing key. + if apiKey, err = resolveAPIKey(); err != nil { + return err + } + imageID, err := uploadImageID(cmd.Context(), api.New(apiKey), imageFlag) + if err != nil { + return err + } + paramsMap["image_id"] = imageID + } + hasMaxPages := cmd.Flags().Changed("max-pages") if hasMaxPages && !allPagesFlag { diff --git a/tests/e2e_test.go b/tests/e2e_test.go index ac8d910..5889451 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -1,6 +1,7 @@ package e2e import ( + "bytes" "encoding/json" "os" "os/exec" @@ -149,6 +150,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 := requireKey(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 := requireKey(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 := requireKey(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 := requireKey(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 := requireKey(t) cmd := exec.Command(binaryPath, "--api-key", key, "search", "engine=google", "q=coffee", "num=1", "--all-pages", "--max-pages", "2")