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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ serpapi search engine=google q="coffee shops" location="Austin,TX"
# Use a different engine
serpapi search engine=google_maps q="pizza" ll="@40.7455096,-74.0083012,14z"

# Markdown output (also supports output=html); printed as-is, incompatible with --jq and --all-pages
serpapi search engine=google q=coffee output=md

# With server-side field filtering (reduces response size at API level)
serpapi search --fields "organic_results[].{title,link}" engine=google q=coffee

Expand Down
28 changes: 27 additions & 1 deletion pkg/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ func (c *Client) userAgent() string {
return "serpapi-go-cli/" + version.Version
}

func (c *Client) doGet(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
// getRaw performs a GET request and returns the response body without
// validating its content type. HTTP-level errors are still reported.
func (c *Client) getRaw(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
u, err := url.Parse(c.baseURL + endpoint)
if err != nil {
return nil, &clierrors.NetworkError{Message: "Invalid URL: " + err.Error(), Cause: err}
Expand Down Expand Up @@ -95,6 +97,15 @@ func (c *Client) doGet(ctx context.Context, endpoint string, params map[string]s
}
}

return body, nil
}

func (c *Client) doGet(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
body, err := c.getRaw(ctx, endpoint, params)
if err != nil {
return nil, err
}

// Validate response is JSON (guard against HTML error pages with 200 status).
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '[') {
Expand Down Expand Up @@ -135,6 +146,21 @@ func (c *Client) Search(ctx context.Context, params map[string]string) (json.Raw
return json.RawMessage(body), nil
}

// SearchRaw performs a search request and returns the raw response body
// without JSON validation. Used for non-JSON output formats such as
// output=md or output=html.
func (c *Client) SearchRaw(ctx context.Context, params map[string]string) ([]byte, error) {
p := make(map[string]string, len(params)+1)
for k, v := range params {
p[k] = v
}
if c.apiKey != "" {
p["api_key"] = c.apiKey
}

return c.getRaw(ctx, "/search.json", p)
}

// Account retrieves account information.
func (c *Client) Account(ctx context.Context) (json.RawMessage, error) {
params := map[string]string{"api_key": c.apiKey}
Expand Down
38 changes: 38 additions & 0 deletions pkg/cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import (
"net/url"
"os"
"sort"
"strings"

"github.com/spf13/cobra"

"github.com/serpapi/serpapi-cli/pkg/api"
clierrors "github.com/serpapi/serpapi-cli/pkg/errors"
"github.com/serpapi/serpapi-cli/pkg/output"
"github.com/serpapi/serpapi-cli/pkg/params"
)

Expand All @@ -28,6 +30,7 @@ var searchCmd = &cobra.Command{
Example: ` serpapi search engine=google q=coffee
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 output=md
serpapi search engine=google q=coffee --jq ".organic_results[:3]"
serpapi search engine=google q=coffee --all-pages --max-pages 3`,
Args: cobra.ArbitraryArgs,
Expand Down Expand Up @@ -56,6 +59,34 @@ func runSearch(cmd *cobra.Command, args []string) error {
fmt.Fprintln(os.Stderr, "Warning: --max-pages has no effect without --all-pages")
}

// Non-JSON output formats (e.g. output=md, output=html) are passed through verbatim.
if isRawOutput(paramsMap) {
format := paramsMap["output"]
if jqFlag != "" {
return &clierrors.UsageError{Message: "--jq requires JSON output and cannot be used with output=" + format}
}
if allPagesFlag {
return &clierrors.UsageError{Message: "--all-pages requires JSON output and cannot be used with output=" + format}
}
if fieldsFlag != "" {
fmt.Fprintln(os.Stderr, "Warning: --fields has no effect with output="+format)
}

sp := newSpinner("Searching...")
sp.Start()
defer sp.Stop()
client := api.New(apiKey)
raw, err := client.SearchRaw(cmd.Context(), paramsMap)
if err != nil {
return err
}
sp.Stop()
if err := output.PrintRaw(raw, os.Stdout); err != nil {
return &clierrors.APIError{Message: fmt.Sprintf("Output error: %s", err)}
}
return nil
}

if !allPagesFlag {
sp := newSpinner("Searching...")
sp.Start()
Expand Down Expand Up @@ -142,6 +173,13 @@ func runSearch(cmd *cobra.Command, args []string) error {
return handleOutput(json.RawMessage(bytes.TrimRight(buf.Bytes(), "\n")))
}

// isRawOutput reports whether the output parameter requests a non-JSON
// format (e.g. md, html) that should be printed verbatim.
func isRawOutput(p map[string]string) bool {
format := strings.ToLower(strings.TrimSpace(p["output"]))
return format != "" && format != "json"
}

// extractNextURL pulls the next pagination URL from a search result.
func extractNextURL(result map[string]any) string {
pag, ok := result["serpapi_pagination"]
Expand Down
21 changes: 21 additions & 0 deletions pkg/cmd/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,24 @@ func TestParseNextParams(t *testing.T) {
t.Errorf("expected engine=google, got %s", params["engine"])
}
}

func TestIsRawOutput(t *testing.T) {
cases := []struct {
name string
params map[string]string
want bool
}{
{"no output param", map[string]string{"q": "coffee"}, false},
{"output json", map[string]string{"output": "json"}, false},
{"output JSON uppercase", map[string]string{"output": "JSON"}, false},
{"output empty", map[string]string{"output": ""}, false},
{"output md", map[string]string{"output": "md"}, true},
{"output html", map[string]string{"output": "html"}, true},
{"output md with whitespace", map[string]string{"output": " md "}, true},
}
for _, tc := range cases {
if got := isRawOutput(tc.params); got != tc.want {
t.Errorf("%s: isRawOutput = %v, want %v", tc.name, got, tc.want)
}
}
}
13 changes: 13 additions & 0 deletions pkg/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ func PrintJSON(data []byte) error {
return err
}

// PrintRaw writes raw response bytes to w verbatim,
// ensuring the output ends with a newline.
func PrintRaw(data []byte, w io.Writer) error {
if _, err := w.Write(data); err != nil {
return err
}
if len(data) > 0 && data[len(data)-1] != '\n' {
_, err := fmt.Fprintln(w)
return err
}
return nil
}

// PrintJQValue prints a single jq result value with raw scalar output.
// Strings are unquoted. Numbers and bools are printed as-is.
// Null produces an empty line. Objects and arrays are JSON-encoded.
Expand Down
30 changes: 30 additions & 0 deletions pkg/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,33 @@ func TestPrintJQValueObject(t *testing.T) {
t.Errorf("expected JSON object, got %q", output)
}
}

func TestPrintRawAddsTrailingNewline(t *testing.T) {
var buf bytes.Buffer
if err := PrintRaw([]byte("# Results\n\n- coffee"), &buf); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != "# Results\n\n- coffee\n" {
t.Errorf("expected trailing newline added, got %q", got)
}
}

func TestPrintRawPreservesTrailingNewline(t *testing.T) {
var buf bytes.Buffer
if err := PrintRaw([]byte("# Results\n"), &buf); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != "# Results\n" {
t.Errorf("expected content unchanged, got %q", got)
}
}

func TestPrintRawEmpty(t *testing.T) {
var buf bytes.Buffer
if err := PrintRaw(nil, &buf); err != nil {
t.Fatal(err)
}
if buf.Len() != 0 {
t.Errorf("expected no output, got %q", buf.String())
}
}
Loading