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 9bf506c..6e4ff31 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. @@ -161,7 +195,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/api/client.go b/pkg/api/client.go index 0245e1d..be8c04a 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -7,9 +7,13 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net" "net/http" + "net/textproto" "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -31,6 +35,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 @@ -97,6 +105,47 @@ func (c *Client) get(ctx context.Context, endpoint string, params map[string]str if err != nil { return nil, &clierrors.NetworkError{Message: "Invalid request: " + endpoint, Cause: err} } + return c.do(req, endpoint) +} + +// 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, endpoint) +} + +// do sends req and returns the body of a 200 response verbatim. +func (c *Client) do(req *http.Request, endpoint string) ([]byte, error) { req.Header.Set("User-Agent", userAgent()) resp, err := c.http.Do(req) @@ -132,6 +181,14 @@ func (c *Client) networkError(endpoint string, err error) error { } var netErr net.Error if c.http.Timeout > 0 && (errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout())) { + // 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", + endpoint, seconds(c.http.Timeout), suggestedTimeout(c.http.Timeout)), + 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. "+ @@ -255,6 +312,78 @@ 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) { + 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) + } + + 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 !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 c2b0c55..fbfc4ff 100644 --- a/pkg/api/client_test.go +++ b/pkg/api/client_test.go @@ -2,10 +2,14 @@ package api import ( "context" + "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "strings" "testing" "time" @@ -424,3 +428,194 @@ func TestTimeoutAdviceRaisesTheLimit(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) + } +} + +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) + } +} + +// 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" + 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.go b/pkg/cmd/image.go new file mode 100644 index 0000000..263fff6 --- /dev/null +++ b/pkg/cmd/image.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "encoding/json" + + "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, newClient(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(cmd *cobra.Command, client *api.Client, path string) (json.RawMessage, error) { + ctx := cmd.Context() + var content []byte + if path == "-" { + in := cmd.InOrStdin() + var err error + 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()} + } + } + + 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(cmd *cobra.Command, client *api.Client, path string) (string, error) { + raw, err := uploadImage(cmd, 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/image_test.go b/pkg/cmd/image_test.go new file mode 100644 index 0000000..6e561d4 --- /dev/null +++ b/pkg/cmd/image_test.go @@ -0,0 +1,212 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +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}) +} + +// 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))...) + + 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) { + 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") +} + +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) + }) + } +} + +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) + } +} diff --git a/pkg/cmd/search.go b/pkg/cmd/search.go index b080502..c5c6cd2 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, newClient(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 27b8ea8..d439c36 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -242,6 +242,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",