defmodule ExDuck do @moduledoc """ ExDuck is an Elixir Client for the DuckDuckGo Instant Answer API """ @baseurl "https://duckduckgo.com" @doc """ Query the DuckDuckGo Instant Answer API Will return the raw (JSON-decoded) results from the API Will raise in case of errors ## Examples iex> ExDuck.query!("Elixir Language") |> Map.get("Heading") "Elixir (programming language)" """ def query!("") do %{ type: "unknown", heading: "no results" } end def query!(topic) do Req.get!(@baseurl, params: %{ q: topic, format: "json" } ).body |> Jason.decode!() end @doc """ Query the DuckDuckGo Instant Answer API into a normalized answer Will return a map of normalized results from the API Will raise in case of errors This is exactly the same as calling ```elixir ExDuck.query!("elixir language") |> ExDuck.understand() ``` ## Examples iex> ExDuck.answer!("Elixir Language").heading "Elixir (programming language)" """ def answer!(topic) do query!(topic) |> understand() end @doc """ Normalize a response from the DuckDuckGo Instant Answer API This is the real deal of this library. Querying the API is simply HTTP GET and JSON parsing. This function is where DuckDuckGo's several types of answers are parsed into consistent forms and non-responses are recognized (e.g. calculation answers). I'm not sold on my current state of normalization. But it does suffice. Calling `understand/1` with an already normalized answer is idempotent. ## Examples # a direct answer iex> elixir = ExDuck.query!("Elixir Language") |> ExDuck.understand() iex> elixir |> Map.keys() [:answer, :entity, :heading, :image, :image_caption, :information, :related, :source, :type, :url] # a category answer iex> simpsons_characters = ExDuck.query!("Simpsons Characters") |> ExDuck.understand() iex> simpsons_characters |> Map.keys() [:entries, :heading, :type] iex> simpsons_characters.type == "category" true # understanding is idempotent iex> elixir = ExDuck.query!("Elixir Language") |> ExDuck.understand() iex> elixir == ExDuck.understand(elixir) """ def understand(answer = %{"Type" => "A"}) do image = Map.get(answer, "Image") |> case do nil -> "" "" -> "" path -> "#{@baseurl}#{path}" end %{ type: "answer", heading: Map.get(answer, "Heading"), answer: Map.get(answer, "Abstract"), image: image, image_caption: caption(answer), entity: Map.get(answer, "Entity"), url: Map.get(answer, "AbstractURL"), source: Map.get(answer, "AbstractSource"), information: Map.get(answer, "Infobox") |> case do %{"content" => content} -> content |> Enum.filter(&(&1["data_type"] == "string" || &1["data_type"] == "twitter_profile")) |> Enum.map( &%{ label: &1["label"], value: &1["value"] } ) nil -> "" "" -> "" end, related: answer["RelatedTopics"] |> Enum.map(&related_topics/1) |> List.flatten() } |> Map.reject(fn {_key, value} -> Enum.member?(["", nil, []], value) end) end def understand(answer = %{"Type" => "C"}) do %{ type: "category", heading: answer["Heading"], entries: answer["RelatedTopics"] |> Enum.map(&related_topics/1) |> List.flatten() } end def understand(answer = %{"Type" => "D"}) do %{ type: "disambiguation", heading: "\"#{answer["Heading"]}\" has multiple possible answers", entries: answer["RelatedTopics"] |> Enum.map(&related_topics/1) |> List.flatten() } end def understand(%{"Type" => ""}) do %{ type: "unknown", heading: "no results" } end def understand(%{"AnswerType" => "calc"}) do %{ type: "calculation", heading: "Sorry. Calculations are not supported via the instant answer API" } end def understand(answer = %{"AnswerType" => "conversions"}) do %{ type: "unit", heading: "\"#{answer["Heading"]}\" is a unit of measurement and has other meanings", entries: answer["RelatedTopics"] |> Enum.map(&related_topics/1) |> List.flatten() } end def understand(%{"Type" => "E"}) do %{ type: "error", heading: "error" } end def understand(answer), do: answer @doc """ Translate a normalized answer or raw query results to markdown ## Examples iex> ExDuck.query!("Elixir Language") ...> |> ExDuck.to_markdown() ...> |> String.starts_with?("# Elixir (programming language)") """ def to_markdown(answer = %{"Type" => _type}) do answer |> understand() |> to_markdown() end def to_markdown(answer = %{type: "answer"}) do main = """ # #{answer[:heading]} #{if answer[:image] do "\"#{answer[:image_caption]}\"" end} #{answer[:answer]} Source: #{answer[:source]} — #{answer[:url]} """ info_table = if answer[:information] do start = """ """ body = Enum.map(answer[:information], fn entry -> """ """ end) |> Enum.join("\n") close = """
Label Value
#{entry[:label]} #{entry[:value]}
""" [start, body, close] |> Enum.join("\n") end related_topics = if answer[:related] do start = """ """ body = Enum.map(answer[:related], fn entry -> """ """ end) |> Enum.join("\n") close = """
Related
#{entry[:text]}
""" [start, body, close] |> Enum.join("\n") end [main, info_table, related_topics] |> Enum.join("\n") end def to_markdown(answer = %{type: "unknown"}) do """ no results for #{answer[:type]} """ end def to_markdown(answer = %{type: type}) when type in ["category", "disambiguation", "unit"] do category_count = answer[:entries] |> Enum.map(& &1[:category]) |> Enum.uniq() |> Enum.count() to_markdown(answer, category_count) end def to_markdown(answer = %{type: "calculation"}) do answer[:heading] end defp to_markdown(answer, 1) do header = """ # #{answer[:heading]} """ body = Enum.map(answer[:entries], fn entry -> """ """ end) |> Enum.join("\n") footer = """
Image Text
#{entry[:text]}
""" [header, body, footer] |> Enum.join("\n") end defp to_markdown(answer, _category_count) do header = """ # #{answer[:heading]} """ body = Enum.map(answer[:entries], fn entry -> """ """ end) |> Enum.join("\n") footer = """
Category Image Text
#{entry[:category]} #{entry[:text]}
""" [header, body, footer] |> Enum.join("\n") end defp related_topics(%{"Name" => category, "Topics" => topics}) do topics |> Enum.map(&related_topics(&1, category)) end defp related_topics(entry) do related_topics(entry, "General") end defp related_topics(entry, category) do %{ url: entry["FirstURL"], image: icon(entry), text: result(entry), category: category } end defp icon(%{"Icon" => %{"URL" => ""}}) do nil end defp icon(%{"Icon" => %{"URL" => path}}) do "#{@baseurl}#{path}" end defp icon(_entry) do nil end defp caption(%{"Heading" => heading, "Infobox" => %{"meta" => meta}}) do meta |> Enum.find(&(&1["label"] == "caption")) |> case do nil -> heading label -> label["value"] |> String.replace("\"", """) end end defp caption(_answer), do: nil defp result(%{"Result" => result}) do result |> String.replace(~r/<\/a>/, "
") end defp result(_entry) do nil end end