Skip to main content

lib/serpcheap.ex

defmodule SerpCheap do
  @moduledoc """
  Elixir client for the [serp.cheap](https://serp.cheap) Google Search API.

  Configure it via application env (the Phoenix way):

      config :serpcheap, api_key: System.get_env("SERPCHEAP_API_KEY")

  Then call from anywhere — options can also be overridden per call:

      {:ok, results} = SerpCheap.search("best running shoes", gl: "us")
  """

  alias SerpCheap.Client

  @type opts :: keyword()
  @type response :: {:ok, map()} | {:error, SerpCheap.Error.t()}

  @doc "Run a Google search. Retries transient errors (429/503/timeout) with backoff."
  @spec search(String.t(), opts()) :: response()
  def search(query, opts \\ []) when is_binary(query) do
    payload =
      %{"q" => query, "gl" => Keyword.get(opts, :gl, "us"), "page" => Keyword.get(opts, :page, 1)}
      |> put(opts, :hl, "hl")
      |> put(opts, :tbs, "tbs")

    Client.request("/v1/search", payload, opts, &is_list(&1["organic"]))
  end

  @doc "Fetch and extract a single page (content + optional screenshot)."
  @spec scrape(String.t(), opts()) :: response()
  def scrape(url, opts \\ []) when is_binary(url) do
    payload =
      %{"url" => url}
      |> put(opts, :render_js, "render_js")
      |> put(opts, :screenshot, "screenshot")
      |> put(opts, :wait_for, "wait_for")
      |> put(opts, :wait_ms, "wait_ms")

    Client.request("/v1/scrape", payload, opts, &is_binary(&1["url"]))
  end

  @doc "Find where a url/domain ranks for a keyword across Google result pages."
  @spec rank(String.t(), String.t(), opts()) :: response()
  def rank(url, query, opts \\ []) when is_binary(url) and is_binary(query) do
    payload =
      %{
        "url" => url,
        "q" => query,
        "gl" => Keyword.get(opts, :gl, "us"),
        "pages" => Keyword.get(opts, :pages, 1),
        "match_type" => Keyword.get(opts, :match_type, "domain")
      }
      |> put(opts, :hl, "hl")
      |> put(opts, :tbs, "tbs")

    Client.request(
      "/v1/rank",
      payload,
      opts,
      &(is_list(&1["organic"]) and is_list(&1["matches"]))
    )
  end

  @doc "Like `search/2` but returns the result or raises `SerpCheap.Error`."
  @spec search!(String.t(), opts()) :: map()
  def search!(query, opts \\ []), do: unwrap(search(query, opts))

  @doc "Like `scrape/2` but returns the result or raises `SerpCheap.Error`."
  @spec scrape!(String.t(), opts()) :: map()
  def scrape!(url, opts \\ []), do: unwrap(scrape(url, opts))

  @doc "Like `rank/3` but returns the result or raises `SerpCheap.Error`."
  @spec rank!(String.t(), String.t(), opts()) :: map()
  def rank!(url, query, opts \\ []), do: unwrap(rank(url, query, opts))

  defp unwrap({:ok, value}), do: value
  defp unwrap({:error, error}), do: raise(error)

  defp put(payload, opts, key, field) do
    case Keyword.get(opts, key) do
      nil -> payload
      value -> Map.put(payload, field, value)
    end
  end
end