diff --git a/README.md b/README.md index 53590cf..8e48ba6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pkg/api/client.go b/pkg/api/client.go index 1c9654d..71a3bf1 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -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} @@ -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] != '[') { @@ -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} diff --git a/pkg/cmd/search.go b/pkg/cmd/search.go index b6c21bb..702a911 100644 --- a/pkg/cmd/search.go +++ b/pkg/cmd/search.go @@ -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" ) @@ -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, @@ -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() @@ -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"] diff --git a/pkg/cmd/search_test.go b/pkg/cmd/search_test.go index c7459e5..d7df0ff 100644 --- a/pkg/cmd/search_test.go +++ b/pkg/cmd/search_test.go @@ -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) + } + } +} diff --git a/pkg/output/output.go b/pkg/output/output.go index 6817e79..26b6f71 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -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. diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go index e4b5e52..19eeac9 100644 --- a/pkg/output/output_test.go +++ b/pkg/output/output_test.go @@ -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()) + } +}