Skip to main content

lib/kagi.ex

defmodule Kagi do
  @moduledoc """
  Typed client for [Kagi](https://kagi.com) Search, Summarizer, and Maps.

  Configure `:session_token` before calling the one-off helpers, or build a
  reusable `Kagi.Client` with `new/0`. Requests are built with `Req` and sent
  through `CloakedReq`.

  ## Quick start

      config :kagi_ex, session_token: "..."
      client = Kagi.new!()
      {:ok, search} = Kagi.search(client, "elixir req", lens: :programming, limit: 5)
      {:ok, summary} = Kagi.summarize(client, "https://elixir-lang.org")
      {:ok, places} = Kagi.maps(client, "coffee zurich", ll: "47.3769,8.5417")

  Non-bang functions return `{:ok, struct}` or `{:error, %Kagi.Error{}}`.
  Bang functions return the struct or raise `Kagi.Error`.
  """

  alias Kagi.Client
  alias Kagi.Error
  alias Kagi.Maps
  alias Kagi.Search
  alias Kagi.Summary

  @typedoc """
  Query argument accepted by `search/1..3` and `maps/1..3`.

  Lists are joined with spaces before the request is made.
  """
  @type query :: String.t() | [String.t()]

  @doc """
  Builds a reusable `Kagi.Client`.

  Reads `:session_token` and `:req_options` from application config. If the
  session token is missing or invalid, returns
  `{:error, %Kagi.Error{reason: :missing_session_token}}`.

  ## Application config

    * `:session_token` - Kagi session token string.
    * `:req_options` - keyword list merged into every `Req` request.

  Returns `{:error, %Kagi.Error{reason: :invalid_option}}` when
  `:req_options` is not a keyword list.

  ## Examples

      config :kagi_ex, session_token: "abc"
      {:ok, client} = Kagi.new()
  """
  @spec new() :: {:ok, Client.t()} | {:error, Error.t()}
  defdelegate new, to: Client

  @doc """
  Builds a reusable `Kagi.Client` or raises `Kagi.Error`.
  """
  @spec new!() :: Client.t()
  defdelegate new!, to: Client

  @doc """
  Searches Kagi and returns typed results.

  Accepts a prebuilt `Kagi.Client`. Use `search/2` or `search/1` to build the
  client from application config.

  ## Search options

    * `:limit` - maximum result count (default `10`); applied client-side.
    * `:region` - region code such as `"ch"`, `"us"`, `"de"`, or `"no_region"`.
    * `:lens` - `:default`, `:programming`, `:forums`, `:pdfs`,
      `:non_commercial`, or `:world_news`.
    * `:sort` - `:recency`, `:website`, or `:ad_trackers`.
    * `:time` - `:day`, `:week`, `:month`, or `:year`.
    * `:from` - start date as `YYYY-MM-DD`; cannot be combined with `:time`.
    * `:to` - end date as `YYYY-MM-DD`; cannot be combined with `:time`.
    * `:site` - appends a `site:` filter to the query.
    * `:filetype` - appends a `filetype:` filter to the query.
    * `:verbatim` - disables query expansion when `true`.

  Returns `{:error, %Kagi.Error{}}` for invalid options, HTTP failures,
  CAPTCHA/challenge pages, and parse failures.

  ## Examples

      client = Kagi.new!()
      {:ok, %Kagi.Search{results: results}} =
        Kagi.search(client, "elixir req http client", lens: :programming, limit: 3)

      Kagi.search("elixir lang", limit: 5)
  """
  @spec search(Client.t(), query(), keyword()) :: {:ok, Search.t()} | {:error, Error.t()}
  def search(%Client{} = client, query, options) do
    Search.request(client, query, options)
  end

  @doc """
  Searches Kagi with either a prebuilt client or application config.

  `search(client, query)` uses default search options. `search(query, options)`
  builds a client from application config and applies the supplied search
  options.
  """
  @spec search(Client.t(), query()) :: {:ok, Search.t()} | {:error, Error.t()}
  @spec search(query(), keyword()) :: {:ok, Search.t()} | {:error, Error.t()}
  def search(%Client{} = client, query), do: search(client, query, [])

  def search(query, options) when is_list(options) do
    with {:ok, client} <- Client.new() do
      Search.request(client, query, options)
    end
  end

  @doc """
  Searches Kagi using application config and default search options.

  Requires `config :kagi_ex, :session_token, "..."` or returns `{:error,
  %Kagi.Error{reason: :missing_session_token}}`.
  """
  @spec search(query()) :: {:ok, Search.t()} | {:error, Error.t()}
  def search(query), do: search(query, [])

  @doc """
  Searches Kagi and raises `Kagi.Error` on failure.
  """
  @spec search!(Client.t(), query(), keyword()) :: Search.t()
  def search!(%Client{} = client, query, options) do
    unwrap!(search(client, query, options))
  end

  @doc """
  Searches Kagi and raises `Kagi.Error` on failure.
  """
  @spec search!(Client.t(), query()) :: Search.t()
  @spec search!(query(), keyword()) :: Search.t()
  def search!(%Client{} = client, query), do: search!(client, query, [])

  def search!(query, options) when is_list(options) do
    unwrap!(search(query, options))
  end

  @doc """
  Searches Kagi with application config and raises `Kagi.Error` on failure.
  """
  @spec search!(query()) :: Search.t()
  def search!(query), do: search!(query, [])

  @doc """
  Summarizes a single URL with Kagi Summarizer.

  Accepts a prebuilt `Kagi.Client`. Use `summarize/2` or `summarize/1` to
  build the client from application config.

  ## Summary options

    * `:type` - `:summary` (default) or `:takeaway`.
    * `:lang` - target language code, default `"EN"`.

  Returns `{:error, %Kagi.Error{}}` for invalid options, HTTP failures,
  and parse failures.

  ## Examples

      client = Kagi.new!()
      {:ok, %Kagi.Summary{summary: markdown}} =
        Kagi.summarize(client, "https://www.rust-lang.org/learn", type: :takeaway)
  """
  @spec summarize(Client.t(), String.t(), keyword()) :: {:ok, Summary.t()} | {:error, Error.t()}
  def summarize(%Client{} = client, url, options) do
    Summary.request(client, url, options)
  end

  @doc """
  Summarizes a URL with either a prebuilt client or application config.

  `summarize(client, url)` uses default summary options. `summarize(url,
  options)` builds a client from application config and applies the supplied
  summary options.
  """
  @spec summarize(Client.t(), String.t()) :: {:ok, Summary.t()} | {:error, Error.t()}
  @spec summarize(String.t(), keyword()) :: {:ok, Summary.t()} | {:error, Error.t()}
  def summarize(%Client{} = client, url), do: summarize(client, url, [])

  def summarize(url, options) when is_list(options) do
    with {:ok, client} <- Client.new() do
      Summary.request(client, url, options)
    end
  end

  @doc """
  Summarizes a URL using application config and default summary options.
  """
  @spec summarize(String.t()) :: {:ok, Summary.t()} | {:error, Error.t()}
  def summarize(url), do: summarize(url, [])

  @doc """
  Summarizes a URL and raises `Kagi.Error` on failure.
  """
  @spec summarize!(Client.t(), String.t(), keyword()) :: Summary.t()
  def summarize!(%Client{} = client, url, options) do
    unwrap!(summarize(client, url, options))
  end

  @doc """
  Summarizes a URL and raises `Kagi.Error` on failure.
  """
  @spec summarize!(Client.t(), String.t()) :: Summary.t()
  @spec summarize!(String.t(), keyword()) :: Summary.t()
  def summarize!(%Client{} = client, url), do: summarize!(client, url, [])

  def summarize!(url, options) when is_list(options) do
    unwrap!(summarize(url, options))
  end

  @doc """
  Summarizes a URL with application config and raises `Kagi.Error` on failure.
  """
  @spec summarize!(String.t()) :: Summary.t()
  def summarize!(url), do: summarize!(url, [])

  @doc """
  Searches Kagi Maps for places matching a query.

  Accepts a prebuilt `Kagi.Client`. Use `maps/2` or `maps/1` to build the
  client from application config.

  ## Maps options

    * `:limit` - maximum result count (default `10`); applied client-side
      after sorting.
    * `:ll` - center coordinate as `"LAT,LON"` (e.g. `"47.3769,8.5417"`).
    * `:bbox` - bounding box as `"WEST,SOUTH,EAST,NORTH"`.
    * `:zoom` - zoom level as a number.
    * `:sort` - `:relevance` (server order), `:rating`, `:distance`, or
      `:price`. Price sorts by `$`-string length.
    * `:order` - `:asc` or `:desc`. Defaults are `:desc` for `:rating`,
      `:asc` for `:distance` and `:price`. `nil` values always sort last.

  Sorting and the limit apply client-side, after the API response is parsed.

  ## Examples

      client = Kagi.new!()
      {:ok, %Kagi.Maps{results: results}} =
        Kagi.maps(client, "coffee zurich", ll: "47.3769,8.5417", sort: :rating)
  """
  @spec maps(Client.t(), query(), keyword()) :: {:ok, Maps.t()} | {:error, Error.t()}
  def maps(%Client{} = client, query, options) do
    Maps.request(client, query, options)
  end

  @doc """
  Searches Kagi Maps with either a prebuilt client or application config.

  `maps(client, query)` uses default Maps options. `maps(query, options)`
  builds a client from application config and applies the supplied Maps
  options.
  """
  @spec maps(Client.t(), query()) :: {:ok, Maps.t()} | {:error, Error.t()}
  @spec maps(query(), keyword()) :: {:ok, Maps.t()} | {:error, Error.t()}
  def maps(%Client{} = client, query), do: maps(client, query, [])

  def maps(query, options) when is_list(options) do
    with {:ok, client} <- Client.new() do
      Maps.request(client, query, options)
    end
  end

  @doc """
  Searches Kagi Maps using application config and default Maps options.
  """
  @spec maps(query()) :: {:ok, Maps.t()} | {:error, Error.t()}
  def maps(query), do: maps(query, [])

  @doc """
  Searches Kagi Maps and raises `Kagi.Error` on failure.
  """
  @spec maps!(Client.t(), query(), keyword()) :: Maps.t()
  def maps!(%Client{} = client, query, options) do
    unwrap!(maps(client, query, options))
  end

  @doc """
  Searches Kagi Maps and raises `Kagi.Error` on failure.
  """
  @spec maps!(Client.t(), query()) :: Maps.t()
  @spec maps!(query(), keyword()) :: Maps.t()
  def maps!(%Client{} = client, query), do: maps!(client, query, [])

  def maps!(query, options) when is_list(options) do
    unwrap!(maps(query, options))
  end

  @doc """
  Searches Kagi Maps with application config and raises `Kagi.Error` on failure.
  """
  @spec maps!(query()) :: Maps.t()
  def maps!(query), do: maps!(query, [])

  @spec unwrap!({:ok, value} | {:error, Error.t()}) :: value when value: var
  defp unwrap!({:ok, value}), do: value
  defp unwrap!({:error, %Error{} = error}), do: raise(error)
end