From 5a768a17d4cac5476102946e10c002fb70361e70 Mon Sep 17 00:00:00 2001 From: ilyazub Date: Wed, 14 Oct 2026 17:28:55 +0200 Subject: [PATCH] [Image] Stop suggesting image_id for engines that drop it --image's error told agents to upload with `serpapi image` and pass image_id= to another engine, but SerpApi takes image_id only on google_lens: other engines drop it and run a normal, counted search without the image. --image now also takes engine=Google_Lens, which SerpApi lowercases, and refuses url= before uploading, which Lens rejects next to image_id. The help, Cost line and README say what an upload costs, and the README's Lens example uses type=exact_matches instead of the discontinued type=products. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 8 ++++---- pkg/api/client.go | 4 ++-- pkg/api/debug.go | 2 +- pkg/cmd/image.go | 11 +++++++---- pkg/cmd/image_test.go | 20 ++++++++++++++++++-- pkg/cmd/root.go | 4 +++- pkg/cmd/search.go | 11 +++++++---- 7 files changed, 42 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 261db49..233656b 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. 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=`. +- `--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`: other engines drop it and would run a normal search without the image, so they are refused before the upload, and so is `url=`. ```bash # Reverse image search with Google Lens @@ -112,7 +112,7 @@ 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 `. +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 `engine=google_lens` searches, the only engine that takes an `image_id`; image IDs expire after 10 minutes. For a one-off search, prefer `serpapi search engine=google_lens --image `. ```bash # Upload an image file @@ -124,7 +124,7 @@ 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 +serpapi search engine=google_lens image_id=$IMAGE_ID type=exact_matches ``` ### login @@ -217,7 +217,7 @@ A config file that can't be read or isn't valid TOML is reported by path and lin | 1 | `network_error` | No answer, such as a timeout. | | 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. +- **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, and so is an `image` upload, though it needs searches left this month and this hour, and has its own hourly limit, `account_rate_limit_per_hour` in `serpapi account`. - **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 ffebeba..53129e8 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -356,8 +356,8 @@ func (c *Client) Locations(ctx context.Context, params map[string]string) (json. } // 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. +// contains an image_id, which only engine=google_lens takes. 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 { diff --git a/pkg/api/debug.go b/pkg/api/debug.go index a2540be..57c6930 100644 --- a/pkg/api/debug.go +++ b/pkg/api/debug.go @@ -121,7 +121,7 @@ func round3(f float64) float64 { // clientTrace returns httptrace hooks that report each connection phase. // DNS/connect hooks are not fired for connections made via an HTTP proxy; -// GotConn still is, which is why the proxy is logged separately in get. +// GotConn still is, which is why logRequest reports the proxy. func (t *tracer) clientTrace() *httptrace.ClientTrace { if t == nil { return nil diff --git a/pkg/cmd/image.go b/pkg/cmd/image.go index b1bee4c..03ab8ca 100644 --- a/pkg/cmd/image.go +++ b/pkg/cmd/image.go @@ -12,12 +12,15 @@ import ( var imageCmd = &cobra.Command{ Use: "image ", Aliases: []string{"upload-image"}, - Short: "Upload an image for use with image search engines like Google Lens", + Short: "Upload an image for a Google Lens search", 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. +The image_id works only with engine=google_lens, for 10 minutes. Supported +formats are JPG/JPEG, PNG and WebP up to 500 KB. + +An upload doesn't count against your searches, but it needs searches left this +month and this hour, and uploads have their own hourly limit: +account_rate_limit_per_hour in "serpapi account". To upload and search Google Lens in one step, use "serpapi search engine=google_lens --image " instead. diff --git a/pkg/cmd/image_test.go b/pkg/cmd/image_test.go index f494b94..22bc8a7 100644 --- a/pkg/cmd/image_test.go +++ b/pkg/cmd/image_test.go @@ -182,18 +182,34 @@ func TestSearchWithImageRefusesOtherEnginesUnsent(t *testing.T) { }{ {"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=google", []string{"engine=google"}, "--image works only with engine=google_lens, not engine=google; other engines drop image_id and search without the image"}, {"engine=bing_reverse_image", []string{"engine=bing_reverse_image"}, "not engine=bing_reverse_image"}, + {"url as well", []string{"engine=google_lens", "url=https://example.com/a.png"}, "Use either --image or url=, not both"}, } { 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) + msg, _ := wantError(t, res, 2, "usage_error", tc.msg)["message"].(string) + if strings.Contains(msg, "pass image_id") { + t.Errorf("message %q suggests image_id for an engine that drops it", msg) + } }) } } +// SerpApi lowercases the engine, so --image takes google_lens in any case. +func TestSearchWithImageTakesGoogleLensInAnyCase(t *testing.T) { + reqs, uploads := imageAPI(t) + + res := execute(t, "", "--api-key", imageKey, "search", "engine=Google_Lens", "--image", writeImage(t, "photo.png", png1x1)) + + wantOK(t, res) + if n, u := len(reqs.list()), len(uploads()); n != 2 || u != 1 { + t.Errorf("got %d requests and %d uploads, want the upload, then the search", n, u) + } +} + func TestImageRefusesEmptyAndOversizedImagesUnsent(t *testing.T) { big := append(append([]byte{}, png1x1...), make([]byte, 600<<10)...) for _, tc := range []struct { diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 6b1ea50..e6b6871 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -96,7 +96,9 @@ 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.`, +read. account, locations and archive are free, and so is an image upload, +though it needs searches left this month and this hour, and has its own hourly +limit.`, Example: ` serpapi search engine=google q=coffee --fields "organic_results[0:3]" serpapi search engine=google coffee shops --jq ".organic_results[].link" serpapi search engine=google q=coffee output=md diff --git a/pkg/cmd/search.go b/pkg/cmd/search.go index 31a9b3c..1a9a431 100644 --- a/pkg/cmd/search.go +++ b/pkg/cmd/search.go @@ -110,14 +110,17 @@ 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" { + // SerpApi takes image_id only on google_lens, whatever the case; other + // engines drop it and would run a normal, counted search after the upload. + if e := p["engine"]; imageFlag != "" && !strings.EqualFold(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="} + "; other engines drop image_id and search without the image"} + } + if _, exists := p["url"]; exists && imageFlag != "" { + return &clierrors.UsageError{Message: "Use either --image or url=, not both"} } maxPagesSet := cmd.Flags().Changed("max-pages")