Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@
.idea/
.vscode/
tmp/

# Local secrets
.env
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` — 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.
Expand All @@ -95,6 +112,23 @@ Retrieve a previously cached search by ID.
serpapi archive <search-id>
```

### 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 <file>`.

```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.
Expand Down
99 changes: 99 additions & 0 deletions pkg/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
Expand Down
141 changes: 141 additions & 0 deletions pkg/api/client_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading